diff --git a/js/components/MainWindow/MainBalance.js b/js/components/MainWindow/MainBalance.tsx
similarity index 58%
rename from js/components/MainWindow/MainBalance.js
rename to js/components/MainWindow/MainBalance.tsx
index d11899dc..ba9f6188 100644
--- a/js/components/MainWindow/MainBalance.js
+++ b/js/components/MainWindow/MainBalance.tsx
@@ -2,21 +2,28 @@ import React from "react";
import { connect } from "react-redux";
import Balance from "../Balance";
+import { AppState } from "../../types";
-export const offsetFromBalance = balance => {
+interface StateProps {
+ balance: number;
+}
+
+export const offsetFromBalance = (balance: number): number => {
const percent = Math.abs(balance) / 100;
const sprite = Math.floor(percent * 27);
const offset = sprite * 15;
return offset;
};
-const MainBalance = props => (
+const MainBalance = (props: StateProps) => (
);
-const mapStateToProps = state => ({ balance: state.media.balance });
+const mapStateToProps = (state: AppState): StateProps => ({
+ balance: state.media.balance
+});
export default connect(mapStateToProps)(MainBalance);
diff --git a/js/components/MainWindow/Marquee.js b/js/components/MainWindow/Marquee.js
deleted file mode 100644
index c0c90987..00000000
--- a/js/components/MainWindow/Marquee.js
+++ /dev/null
@@ -1,168 +0,0 @@
-// 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 { connect } from "react-redux";
-import { getTimeStr } from "../../utils";
-
-import { STEP_MARQUEE } from "../../actionTypes";
-import CharacterString from "../CharacterString";
-import { getMediaText } from "../../selectors";
-
-const CHAR_WIDTH = 5;
-const MARQUEE_MAX_LENGTH = 31;
-
-// Always positive modulus
-export const mod = (n, m) => ((n % m) + m) % m;
-
-export const getBalanceText = balance => {
- if (balance === 0) {
- return "Balance: Center";
- }
- const direction = balance > 0 ? "Right" : "Left";
- return `Balance: ${Math.abs(balance)}% ${direction}`;
-};
-
-export const getVolumeText = volume => `Volume: ${volume}%`;
-
-export const getPositionText = (duration, seekToPercent) => {
- const newElapsedStr = getTimeStr((duration * seekToPercent) / 100, false);
- const durationStr = getTimeStr(duration, false);
- return `Seek to: ${newElapsedStr}/${durationStr} (${seekToPercent}%)`;
-};
-
-export const getDoubleSizeModeText = enabled =>
- `${enabled ? "Disable" : "Enable"} doublesize mode`;
-
-const formatHz = hz => (hz < 1000 ? `${hz}HZ` : `${hz / 1000}KHZ`);
-
-// Format a number as a string, ensuring it has a + or - sign
-const ensureSign = num => (num > 0 ? `+${num}` : num.toString());
-
-// Round to 1 and exactly 1 decimal point
-const roundToTenths = num => (Math.round(num * 10) / 10).toFixed(1);
-
-export const getEqText = (band, level) => {
- const db = roundToTenths(((level - 50) / 50) * 12);
- const label = band === "preamp" ? "Preamp" : formatHz(band);
- return `EQ: ${label} ${ensureSign(db)} DB`;
-};
-
-const isLong = text => text.length >= MARQUEE_MAX_LENGTH;
-
-// Given text and step, how many pixels should it be shifted?
-export const stepOffset = (text, step, pixels) => {
- 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 * CHAR_WIDTH;
-
- return mod(offset, stringLength);
-};
-
-// Format an int as pixels
-export const pixelUnits = pixels => `${pixels}px`;
-
-// If text is wider than the marquee, it needs to loop
-export const loopText = text =>
- isLong(text) ? text + text : text.padEnd(MARQUEE_MAX_LENGTH, " ");
-
-class Marquee extends React.Component {
- constructor(props) {
- super(props);
- this.state = { stepping: true, dragOffset: 0 };
- this.stepHandle = null;
- }
-
- componentDidMount() {
- this.stepHandle = setInterval(() => {
- if (this.state.stepping) {
- this.props.dispatch({ type: STEP_MARQUEE });
- }
- }, 220);
- }
-
- componentWillUnmount() {
- if (this.stepHandle) {
- clearTimeout(this.stepHandle);
- }
- }
-
- handleMouseDown = e => {
- const xStart = e.clientX;
- this.setState({ stepping: false });
- const handleMouseMove = ee => {
- const diff = ee.clientX - xStart;
- this.setState({ dragOffset: -diff });
- };
-
- const handleMouseUp = () => {
- document.removeEventListener("mousemove", handleMouseMove);
- // TODO: Remove this listener
- setTimeout(() => {
- this.setState({ stepping: true });
- }, 1000);
- document.removeEventListener("mouseUp", handleMouseUp);
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- };
-
- render() {
- const { text, marqueeStep } = this.props;
- const offset = stepOffset(text, marqueeStep, this.state.dragOffset);
- const offsetPixels = pixelUnits(-offset);
- const style = {
- whiteSpace: "nowrap",
- willChange: "transform",
- transform: `translateX(${offsetPixels})`
- };
- return (
-
- );
- }
-}
-
-const getMarqueeText = state => {
- if (state.userInput.userMessage != null) {
- return state.userInput.userMessage;
- }
- switch (state.userInput.focus) {
- case "balance":
- return getBalanceText(state.media.balance);
- case "volume":
- return getVolumeText(state.media.volume);
- case "position":
- return getPositionText(state.media.length, state.userInput.scrubPosition);
- case "double":
- return getDoubleSizeModeText(state.display.doubled);
- case "eq":
- const band = state.userInput.bandFocused;
- return getEqText(band, state.equalizer.sliders[band]);
- default:
- break;
- }
- if (state.playlist.currentTrack != null) {
- return getMediaText(state);
- }
- return "Winamp 2.91";
-};
-
-const mapStateToProps = state => ({
- marqueeStep: state.display.marqueeStep,
- text: getMarqueeText(state)
-});
-
-export default connect(mapStateToProps)(Marquee);
diff --git a/js/components/MainWindow/Marquee.test.js b/js/components/MainWindow/Marquee.test.js
index 96b8f47a..0e0d9509 100644
--- a/js/components/MainWindow/Marquee.test.js
+++ b/js/components/MainWindow/Marquee.test.js
@@ -1,13 +1,4 @@
-import {
- mod,
- getBalanceText,
- getVolumeText,
- getPositionText,
- getDoubleSizeModeText,
- stepOffset,
- pixelUnits,
- loopText
-} from "./Marquee";
+import { mod, stepOffset, pixelUnits, loopText } from "./Marquee";
describe("mod", () => {
it("behaves differently than % for negative numbers", () => {
@@ -18,42 +9,6 @@ describe("mod", () => {
});
});
-describe("getBalanceText", () => {
- it("treats negative numbers as left", () => {
- const actual = getBalanceText(-25);
- const expected = "Balance: 25% Left";
- expect(actual).toEqual(expected);
- });
- it("treats positive numbers as right", () => {
- const actual = getBalanceText(25);
- const expected = "Balance: 25% Right";
- expect(actual).toEqual(expected);
- });
- it("has a special case for center", () => {
- const actual = getBalanceText(0);
- const expected = "Balance: Center";
- expect(actual).toEqual(expected);
- });
-});
-
-describe("getVolumeText", () => {
- it("expresses volume as percent", () => {
- const actual = getVolumeText(50);
- const expected = "Volume: 50%";
- expect(actual).toEqual(expected);
- });
-});
-
-describe("getPositionText", () => {
- it("formats a position", () => {
- const duration = 86;
- const seekToPercent = 85;
- const actual = getPositionText(duration, seekToPercent);
- const expected = "Seek to: 01:13/01:26 (85%)";
- expect(actual).toEqual(expected);
- });
-});
-
describe("stepOffset", () => {
const long = "This is a long string. Longer than 30 characters!";
it("starts at 0", () => {
@@ -124,15 +79,3 @@ describe("loopText", () => {
expect(actual.length).toBe(31);
});
});
-describe("getDoubleSizeModeText", () => {
- it("prompts to enable when disabled", () => {
- const actual = getDoubleSizeModeText(true);
- const expected = "Disable doublesize mode";
- expect(actual).toEqual(expected);
- });
- it("prompts to disable when enabled", () => {
- const actual = getDoubleSizeModeText(false);
- const expected = "Enable doublesize mode";
- expect(actual).toEqual(expected);
- });
-});
diff --git a/js/components/MainWindow/Marquee.tsx b/js/components/MainWindow/Marquee.tsx
new file mode 100644
index 00000000..e6c74738
--- /dev/null
+++ b/js/components/MainWindow/Marquee.tsx
@@ -0,0 +1,140 @@
+// 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 { connect } from "react-redux";
+
+import { STEP_MARQUEE } from "../../actionTypes";
+import CharacterString from "../CharacterString";
+import { getMediaText } from "../../selectors";
+import { Slider, AppState, Dispatch } from "../../types";
+import * as Selectors from "../../selectors";
+
+interface StateProps {
+ marqueeStep: number;
+ text: string;
+}
+interface DispatchProps {
+ stepMarquee(): void;
+}
+
+type Props = StateProps & DispatchProps;
+
+interface State {
+ stepping: boolean;
+ dragOffset: number;
+}
+
+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 * 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 + text : text.padEnd(MARQUEE_MAX_LENGTH, " ");
+
+class Marquee extends React.Component {
+ stepHandle: NodeJS.Timer | null;
+ constructor(props: Props) {
+ super(props);
+ this.state = { stepping: true, dragOffset: 0 };
+ this.stepHandle = null;
+ }
+
+ componentDidMount() {
+ this.stepHandle = setInterval(() => {
+ if (this.state.stepping) {
+ this.props.stepMarquee();
+ }
+ }, 220);
+ }
+
+ componentWillUnmount() {
+ if (this.stepHandle) {
+ clearTimeout(this.stepHandle);
+ }
+ }
+
+ handleMouseDown = (e: React.MouseEvent) => {
+ const xStart = e.clientX;
+ this.setState({ stepping: false });
+ const handleMouseMove = (ee: MouseEvent) => {
+ const diff = ee.clientX - xStart;
+ this.setState({ dragOffset: -diff });
+ };
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ // TODO: Remove this listener
+ setTimeout(() => {
+ this.setState({ stepping: true });
+ }, 1000);
+ document.removeEventListener("mouseUp", handleMouseUp);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ };
+
+ render() {
+ const { text, marqueeStep } = this.props;
+ const offset = stepOffset(text, marqueeStep, this.state.dragOffset);
+ const offsetPixels = pixelUnits(-offset);
+ const style: React.CSSProperties = {
+ whiteSpace: "nowrap",
+ willChange: "transform",
+ transform: `translateX(${offsetPixels})`
+ };
+ return (
+
+ );
+ }
+}
+
+const mapStateToProps = (state: AppState): StateProps => ({
+ marqueeStep: state.display.marqueeStep,
+ text: Selectors.getMarqueeText(state)
+});
+
+const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
+ return {
+ stepMarquee: () => dispatch({ type: STEP_MARQUEE })
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(Marquee);
diff --git a/js/marqueeUtils.test.ts b/js/marqueeUtils.test.ts
new file mode 100644
index 00000000..2766018b
--- /dev/null
+++ b/js/marqueeUtils.test.ts
@@ -0,0 +1,50 @@
+import * as MarqueeUtils from "./marqueeUtils";
+
+describe("getBalanceText", () => {
+ it("treats negative numbers as left", () => {
+ const actual = MarqueeUtils.getBalanceText(-25);
+ const expected = "Balance: 25% Left";
+ expect(actual).toEqual(expected);
+ });
+ it("treats positive numbers as right", () => {
+ const actual = MarqueeUtils.getBalanceText(25);
+ const expected = "Balance: 25% Right";
+ expect(actual).toEqual(expected);
+ });
+ it("has a special case for center", () => {
+ const actual = MarqueeUtils.getBalanceText(0);
+ const expected = "Balance: Center";
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("getVolumeText", () => {
+ it("expresses volume as percent", () => {
+ const actual = MarqueeUtils.getVolumeText(50);
+ const expected = "Volume: 50%";
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("getPositionText", () => {
+ it("formats a position", () => {
+ const duration = 86;
+ const seekToPercent = 85;
+ const actual = MarqueeUtils.getPositionText(duration, seekToPercent);
+ const expected = "Seek to: 01:13/01:26 (85%)";
+ expect(actual).toEqual(expected);
+ });
+});
+
+describe("getDoubleSizeModeText", () => {
+ it("prompts to enable when disabled", () => {
+ const actual = MarqueeUtils.getDoubleSizeModeText(true);
+ const expected = "Disable doublesize mode";
+ expect(actual).toEqual(expected);
+ });
+ it("prompts to disable when enabled", () => {
+ const actual = MarqueeUtils.getDoubleSizeModeText(false);
+ const expected = "Enable doublesize mode";
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/js/marqueeUtils.tsx b/js/marqueeUtils.tsx
new file mode 100644
index 00000000..a2cbebe3
--- /dev/null
+++ b/js/marqueeUtils.tsx
@@ -0,0 +1,45 @@
+import { Slider } from "./types";
+
+import * as Utils from "./utils";
+
+export const getBalanceText = (balance: number): string => {
+ if (balance === 0) {
+ return "Balance: Center";
+ }
+ const direction = balance > 0 ? "Right" : "Left";
+ return `Balance: ${Math.abs(balance)}% ${direction}`;
+};
+
+export const getVolumeText = (volume: number): string => `Volume: ${volume}%`;
+
+export const getPositionText = (
+ duration: number,
+ seekToPercent: number
+): string => {
+ const newElapsedStr = Utils.getTimeStr(
+ (duration * seekToPercent) / 100,
+ false
+ );
+ const durationStr = Utils.getTimeStr(duration, false);
+ return `Seek to: ${newElapsedStr}/${durationStr} (${seekToPercent}%)`;
+};
+
+export const getDoubleSizeModeText = (enabled: boolean): string =>
+ `${enabled ? "Disable" : "Enable"} doublesize mode`;
+
+const formatHz = (hz: number): string =>
+ hz < 1000 ? `${hz}HZ` : `${hz / 1000}KHZ`;
+
+// Format a number as a string, ensuring it has a + or - sign
+const ensureSign = (num: number | string): string =>
+ num > 0 ? `+${num}` : num.toString();
+
+// Round to 1 and exactly 1 decimal point
+const roundToTenths = (num: number): string =>
+ (Math.round(num * 10) / 10).toFixed(1);
+
+export const getEqText = (band: Slider, level: number): string => {
+ const db = roundToTenths(((level - 50) / 50) * 12);
+ const label = band === "preamp" ? "Preamp" : formatHz(band);
+ return `EQ: ${label} ${ensureSign(db)} DB`;
+};
diff --git a/js/selectors.ts b/js/selectors.ts
index 01bcd38c..8711f3c0 100644
--- a/js/selectors.ts
+++ b/js/selectors.ts
@@ -23,9 +23,10 @@ import { createPlaylistURL } from "./playlistHtml";
import * as fromTracks from "./reducers/tracks";
import * as fromDisplay from "./reducers/display";
import * as fromEqualizer from "./reducers/equalizer";
-import * as fromMedia from "./reducers/media";
+import media, * as fromMedia from "./reducers/media";
import * as fromWindows from "./reducers/windows";
import * as TrackUtils from "./trackUtils";
+import * as MarqueeUtils from "./marqueeUtils";
import { generateGraph } from "./resizeUtils";
import { SerializedStateV1 } from "./serializedStates/v1Types";
@@ -494,3 +495,46 @@ export const getStackedLayoutPositions = createSelector(
});
}
);
+
+// TODO: Make this a reselect selector
+export const getMarqueeText = (state: AppState): string => {
+ const defaultText = "Winamp 2.91";
+ if (state.userInput.userMessage != null) {
+ return state.userInput.userMessage;
+ }
+ switch (state.userInput.focus) {
+ case "balance":
+ return MarqueeUtils.getBalanceText(state.media.balance);
+ case "volume":
+ return MarqueeUtils.getVolumeText(state.media.volume);
+ case "position":
+ if (state.media.length == null) {
+ // This probably can't ever happen.
+ return defaultText;
+ }
+ return MarqueeUtils.getPositionText(
+ state.media.length,
+ state.userInput.scrubPosition
+ );
+ case "double":
+ return MarqueeUtils.getDoubleSizeModeText(state.display.doubled);
+ case "eq":
+ const band = state.userInput.bandFocused;
+ if (band == null) {
+ // This probably can't ever happen.
+ return defaultText;
+ }
+ return MarqueeUtils.getEqText(band, state.equalizer.sliders[band]);
+ default:
+ break;
+ }
+ if (state.playlist.currentTrack != null) {
+ const mediaText = getMediaText(state);
+ if (mediaText == null) {
+ // This probably can't ever happen.
+ return defaultText;
+ }
+ return mediaText;
+ }
+ return defaultText;
+};