Last active
January 29, 2026 00:34
-
-
Save greggman/3fae0bad8fe4a2ac71bff5c8761865f4 to your computer and use it in GitHub Desktop.
Canvas2D: video icons
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :root { | |
| color-scheme: dark; | |
| } | |
| canvas { | |
| display: block; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /*bug-in-github-api-content-can-not-be-empty*/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| < = 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() { | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {"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