mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-22 17:47:16 +00:00
154 lines
4.5 KiB
TypeScript
154 lines
4.5 KiB
TypeScript
// Single line text display that can animate and hold multiple registers
|
|
// Knows how to display various modes like tracking, volume, balance, etc.
|
|
import React from "react";
|
|
import CharacterString from "../CharacterString";
|
|
import * as Actions from "../../actionCreators";
|
|
import * as Selectors from "../../selectors";
|
|
import { useTypedSelector, useActionCreator } from "../../hooks";
|
|
|
|
const SEPARATOR = " *** ";
|
|
|
|
const CHAR_WIDTH = 5;
|
|
const MARQUEE_MAX_LENGTH = 31;
|
|
|
|
// Always positive modulus
|
|
export const mod = (n: number, m: number): number => ((n % m) + m) % m;
|
|
|
|
const isLong = (text: string): boolean => text.length >= MARQUEE_MAX_LENGTH;
|
|
|
|
// Given text and step, how many pixels should it be shifted?
|
|
export const stepOffset = (
|
|
text: string,
|
|
step: number,
|
|
pixels: number
|
|
): number => {
|
|
if (!isLong(text)) {
|
|
return 0;
|
|
}
|
|
|
|
const stepOffsetWidth = step * CHAR_WIDTH; // Steps move one char at a time
|
|
const offset = stepOffsetWidth + pixels;
|
|
const stringLength = (text.length + SEPARATOR.length) * CHAR_WIDTH;
|
|
|
|
return mod(offset, stringLength);
|
|
};
|
|
|
|
// Format an int as pixels
|
|
export const pixelUnits = (pixels: number): string => `${pixels}px`;
|
|
|
|
// If text is wider than the marquee, it needs to loop
|
|
export const loopText = (text: string): string =>
|
|
isLong(text)
|
|
? `${text}${SEPARATOR}${text}`
|
|
: text.padEnd(MARQUEE_MAX_LENGTH, " ");
|
|
|
|
interface UseStepperArgs {
|
|
step: () => void;
|
|
dragging: boolean;
|
|
}
|
|
|
|
// Call `step` every second, except when dragging. Resume stepping 1 second after dragging ceases.
|
|
function useStepper({ step, dragging }: UseStepperArgs): void {
|
|
const [stepping, setStepping] = React.useState(true);
|
|
React.useEffect(() => {
|
|
if (stepping === false) {
|
|
return;
|
|
}
|
|
const stepHandle = setInterval(step, 220);
|
|
return () => clearInterval(stepHandle);
|
|
}, [step, stepping]);
|
|
|
|
React.useEffect(() => {
|
|
if (dragging) {
|
|
setStepping(false);
|
|
return;
|
|
}
|
|
const steppingTimeout = window.setTimeout(() => {
|
|
setStepping(true);
|
|
}, 1000);
|
|
return () => {
|
|
window.clearTimeout(steppingTimeout);
|
|
};
|
|
}, [dragging]);
|
|
}
|
|
|
|
// When user calls `handleMouseDown`, and moves the mouse, `dragOffset` will update as they drag.
|
|
function useDragX() {
|
|
const [mouseDownX, setMouseDownX] = React.useState<number | null>(null);
|
|
const [dragOffset, setDragOffset] = React.useState(0);
|
|
|
|
React.useEffect(() => {
|
|
if (mouseDownX == null) {
|
|
return;
|
|
}
|
|
const xStart = mouseDownX;
|
|
const handleMouseMove = (ee: MouseEvent) => {
|
|
const diff = ee.clientX - xStart;
|
|
setDragOffset(-diff);
|
|
};
|
|
|
|
// TODO: Use `once` or something instead of this flag nonsense
|
|
let cleanedUp = false;
|
|
const handleMouseUp = () => {
|
|
if (cleanedUp) {
|
|
return;
|
|
}
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseUp", handleMouseUp);
|
|
setMouseDownX(null);
|
|
cleanedUp = true;
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
return handleMouseUp;
|
|
}, [mouseDownX]);
|
|
|
|
const handleMouseDown = React.useCallback(
|
|
(e: React.MouseEvent<HTMLDivElement>): void => {
|
|
setMouseDownX(e.clientX);
|
|
},
|
|
[]
|
|
);
|
|
|
|
return { handleMouseDown, dragOffset, dragging: mouseDownX != null };
|
|
}
|
|
|
|
const Marquee = React.memo(() => {
|
|
const text = useTypedSelector(Selectors.getMarqueeText);
|
|
const doubled = useTypedSelector(Selectors.getDoubled);
|
|
const marqueeStep = useTypedSelector(Selectors.getMarqueeStep);
|
|
const stepMarquee = useActionCreator(Actions.stepMarquee);
|
|
const { handleMouseDown, dragOffset, dragging } = useDragX();
|
|
const offset = stepOffset(text, marqueeStep, dragOffset);
|
|
const offsetPixels = pixelUnits(-offset);
|
|
|
|
useStepper({ step: stepMarquee, dragging });
|
|
|
|
return (
|
|
<div
|
|
id="marquee"
|
|
className="text"
|
|
onMouseDown={handleMouseDown}
|
|
title="Song Title"
|
|
>
|
|
<div
|
|
style={{
|
|
whiteSpace: "nowrap",
|
|
willChange: "transform",
|
|
transform: `translateX(${offsetPixels})`,
|
|
}}
|
|
// Force the DOM node to be recreated when the doubled size changes.
|
|
// This works around a Chrome browser bug where the `will-change: transform;`
|
|
// on this node seems to cause a change to the `image-rendering:
|
|
// pixelated;` which we inherit from `#webamp` not to be respected.
|
|
key={doubled ? "doubled" : "not-doubled"}
|
|
>
|
|
<CharacterString>{loopText(text)}</CharacterString>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default Marquee;
|