Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/webamp/js/actionCreators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export {
toggleRandomizePresets,
togglePresetCycling,
scheduleMilkdropMessage,
toggleReduceFlashing,
} from "./milkdrop";

export function close(): Thunk {
Expand Down
4 changes: 4 additions & 0 deletions packages/webamp/js/actionCreators/milkdrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,7 @@ export function togglePresetCycling(): Action {
export function scheduleMilkdropMessage(message: string): Action {
return { type: "SCHEDULE_MILKDROP_MESSAGE", message };
}

export function toggleReduceFlashing(): Action {
return { type: "TOGGLE_REDUCE_FLASHING" };
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ interface Props {

const MilkdropContextMenu = (props: Props) => {
const desktop = useTypedSelector(Selectors.getMilkdropDesktopEnabled);
const reduceFlashing = useTypedSelector(Selectors.getReduceFlashing);

const closeWindow = useActionCreator(Actions.closeWindow);
const toggleReduceFlashing = useActionCreator(Actions.toggleReduceFlashing);
const toggleDesktop = useActionCreator(Actions.toggleMilkdropDesktop);
const toggleFullscreen = useActionCreator(Actions.toggleMilkdropFullscreen);

Expand All @@ -36,6 +38,12 @@ const MilkdropContextMenu = (props: Props) => {
hotkey="Alt+D"
/>
<Hr />
<Node
onClick={toggleReduceFlashing}
checked={reduceFlashing}
label="Reduce Flashing (WCAG 2.3.1)"
/>
<Hr />
<Node onClick={() => closeWindow(WINDOWS.MILKDROP)} label="Quit" />
</>
);
Expand Down
77 changes: 70 additions & 7 deletions packages/webamp/js/components/MilkdropWindow/Visualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { VISUALIZERS } from "../../constants";
import * as Selectors from "../../selectors";
import { TransitionType } from "../../types";
import { useTypedSelector } from "../../hooks";
import { createFlashGovernor } from "./flashGovernor";

type ButterchurnVisualizer = {
setRendererSize(width: number, height: number): void;
Expand Down Expand Up @@ -31,10 +32,13 @@ function Visualizer({ analyser, width, height }: Props) {
const currentPreset = useTypedSelector(Selectors.getCurrentPreset);
const transitionType = useTypedSelector(Selectors.getPresetTransitionType);
const message = useTypedSelector(Selectors.getMilkdropMessage);
const reduceFlashing = useTypedSelector(Selectors.getReduceFlashing);

const isEnabledVisualizer = visualizerStyle === VISUALIZERS.MILKDROP;

const canvasRef = useRef(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const governorRef = useRef(createFlashGovernor());
const [isDamping, setIsDamping] = useState<boolean>(false);
const [visualizer, setVisualizer] = useState<ButterchurnVisualizer | null>(
null
);
Expand Down Expand Up @@ -81,6 +85,12 @@ function Visualizer({ analyser, width, height }: Props) {
// Load presets when they change
const hasLoadedPreset = useRef<boolean>(false);
useEffect(() => {
governorRef.current.reset();
setIsDamping(false);
if (canvasRef.current) {
canvasRef.current.style.filter = "";
}

if (visualizer == null || currentPreset == null) {
return;
}
Expand Down Expand Up @@ -128,32 +138,85 @@ function Visualizer({ analyser, width, height }: Props) {
// Kick off the animation loop
useEffect(() => {
if (!shouldAnimate || visualizer == null) {
if (canvas) {
canvas.style.filter = "";
}
setIsDamping(false);
return;
}
const canvas = canvasRef.current;
let animationFrameRequest: number | null = null;
const loop = () => {
visualizer.render();

if (reduceFlashing && canvasRef.current) {
const decision = governorRef.current.tick(
canvasRef.current,
performance.now()
);
canvasRef.current.style.filter = decision.isDamping
? `brightness(${decision.scale.toFixed(3)})`
: "";
setIsDamping(decision.isDamping);
} else if (canvasRef.current && canvasRef.current.style.filter !== "") {
canvasRef.current.style.filter = "";
setIsDamping(false);
}

animationFrameRequest = window.requestAnimationFrame(loop);
};
loop();
return () => {
if (animationFrameRequest != null) {
window.cancelAnimationFrame(animationFrameRequest);
}
if (canvas) {
canvas.style.filter = "";
}
};
}, [visualizer, shouldAnimate]);
}, [visualizer, shouldAnimate, reduceFlashing]);

return (
<canvas
height={height}
width={width}
<div
style={{
position: "relative",
height: "100%",
width: "100%",
display: isEnabledVisualizer ? "block" : "none",
}}
ref={canvasRef}
/>
>
<canvas
height={height}
width={width}
style={{
height: "100%",
width: "100%",
display: "block",
}}
ref={canvasRef}
/>
{isDamping && (
<div
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(0, 0, 0, 0.7)",
color: "#fff",
fontSize: 10,
padding: "2px 6px",
borderRadius: 3,
pointerEvents: "none",
letterSpacing: 0.5,
fontFamily: "sans-serif",
textTransform: "uppercase",
zIndex: 10,
}}
>
Dimming Flashes
</div>
)}
</div>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, test, expect } from "vitest";
import {
createFlashGovernor,
relativeLuminance,
GovernorDecision,
} from "../flashGovernor";

describe("relativeLuminance", () => {
test("computes correct CIE 1931 relative luminance", () => {
expect(relativeLuminance(0, 0, 0)).toBeCloseTo(0, 4);
expect(relativeLuminance(255, 255, 255)).toBeCloseTo(1.0, 4);
// Green has highest photometric weight
expect(relativeLuminance(0, 255, 0)).toBeCloseTo(0.7152, 2);
expect(relativeLuminance(255, 0, 0)).toBeCloseTo(0.2126, 2);
expect(relativeLuminance(0, 0, 255)).toBeCloseTo(0.0722, 2);
});
});

describe("createFlashGovernor", () => {
function createMockCanvas(getPixelLum: () => number): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;

// Mock 2D context for the 8x8 sampleCanvas created inside governor
const origCreateElement = document.createElement.bind(document);
document.createElement = function (tagName: string) {
const el = origCreateElement(tagName);
if (tagName === "canvas") {
(el as any).getContext = (type: string) => {
if (type === "2d") {
return {
drawImage: () => {},
getImageData: () => {
const lum = getPixelLum();
const byteVal = Math.round(lum * 255);
const data = new Uint8ClampedArray(64 * 4);
for (let i = 0; i < data.length; i += 4) {
data[i] = byteVal;
data[i + 1] = byteVal;
data[i + 2] = byteVal;
data[i + 3] = 255;
}
return { data };
},
} as any;
}
return null;
};
}
return el;
};

return canvas;
}

test("maintains 1.0 scale during calm, non-flashing animation", () => {
const governor = createFlashGovernor();
let lum = 0.5;
const canvas = createMockCanvas(() => lum);

for (let frame = 0; frame < 60; frame++) {
lum = 0.5 + Math.sin(frame * 0.05) * 0.02; // very gentle subtle change
const decision = governor.tick(canvas, frame * 16.6);
expect(decision.isDamping).toBe(false);
expect(decision.scale).toBeCloseTo(1.0, 1);
}
});

test("clamps brightness when encountering rapid flashing (> 3 transitions/sec)", () => {
const governor = createFlashGovernor();
let lum = 0.0;
const canvas = createMockCanvas(() => lum);

// Strobe between black (0.0) and white (1.0) every 33ms (~15Hz strobe)
let lastDecision: GovernorDecision = governor.getDecision();
for (let frame = 0; frame < 30; frame++) {
lum = frame % 2 === 0 ? 0.0 : 1.0;
lastDecision = governor.tick(canvas, frame * 33.3);
}

expect(lastDecision.transitionCount).toBeGreaterThan(3);
expect(lastDecision.isDamping).toBe(true);
expect(lastDecision.scale).toBeLessThan(0.7);
});

test("recovers brightness smoothly when strobing stops", () => {
const governor = createFlashGovernor();
let lum = 0.0;
const canvas = createMockCanvas(() => lum);

// Strobe first to trigger damping
for (let frame = 0; frame < 20; frame++) {
lum = frame % 2 === 0 ? 0.0 : 1.0;
governor.tick(canvas, frame * 33.3);
}

expect(governor.getDecision().isDamping).toBe(true);

// Now calm down for 2 seconds (constant calm frame)
lum = 0.5;
let recoveredDecision: GovernorDecision = governor.getDecision();
for (let frame = 21; frame < 150; frame++) {
recoveredDecision = governor.tick(canvas, frame * 33.3);
}

expect(recoveredDecision.transitionCount).toBe(0);
expect(recoveredDecision.isDamping).toBe(false);
expect(recoveredDecision.scale).toBeGreaterThan(0.95);
});

test("reset() immediately clears transitions and restores scale", () => {
const governor = createFlashGovernor();
let lum = 0.0;
const canvas = createMockCanvas(() => lum);

for (let frame = 0; frame < 20; frame++) {
lum = frame % 2 === 0 ? 0.0 : 1.0;
governor.tick(canvas, frame * 33.3);
}
expect(governor.getDecision().isDamping).toBe(true);

governor.reset();
expect(governor.getDecision().isDamping).toBe(false);
expect(governor.getDecision().scale).toBe(1.0);
expect(governor.getDecision().transitionCount).toBe(0);
});
});
Loading