From 30f668b871e1e8b982ea0537d7c8a38c05964e4b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Sat, 13 Oct 2018 17:33:43 -0700 Subject: [PATCH] Type Marquee --- .../{MainBalance.js => MainBalance.tsx} | 13 +- js/components/MainWindow/Marquee.js | 168 ------------------ js/components/MainWindow/Marquee.test.js | 59 +----- js/components/MainWindow/Marquee.tsx | 140 +++++++++++++++ js/marqueeUtils.test.ts | 50 ++++++ js/marqueeUtils.tsx | 45 +++++ js/selectors.ts | 46 ++++- 7 files changed, 291 insertions(+), 230 deletions(-) rename js/components/MainWindow/{MainBalance.js => MainBalance.tsx} (58%) delete mode 100644 js/components/MainWindow/Marquee.js create mode 100644 js/components/MainWindow/Marquee.tsx create mode 100644 js/marqueeUtils.test.ts create mode 100644 js/marqueeUtils.tsx 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 ( -
-
- {loopText(text)} -
-
- ); - } -} - -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 ( +
+
+ {loopText(text)} +
+
+ ); + } +} + +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; +};