mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-19 09:33:47 +00:00
Type Marquee
This commit is contained in:
parent
90d5f1b3c8
commit
30f668b871
7 changed files with 291 additions and 230 deletions
|
|
@ -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) => (
|
||||
<Balance
|
||||
id="balance"
|
||||
style={{ backgroundPosition: `0 -${offsetFromBalance(props.balance)}px` }}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapStateToProps = state => ({ balance: state.media.balance });
|
||||
const mapStateToProps = (state: AppState): StateProps => ({
|
||||
balance: state.media.balance
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(MainBalance);
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
id="marquee"
|
||||
className="text"
|
||||
onMouseDown={this.handleMouseDown}
|
||||
title="Song Title"
|
||||
>
|
||||
<div style={style}>
|
||||
<CharacterString>{loopText(text)}</CharacterString>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
140
js/components/MainWindow/Marquee.tsx
Normal file
140
js/components/MainWindow/Marquee.tsx
Normal file
|
|
@ -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<Props, State> {
|
||||
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 (
|
||||
<div
|
||||
id="marquee"
|
||||
className="text"
|
||||
onMouseDown={this.handleMouseDown}
|
||||
title="Song Title"
|
||||
>
|
||||
<div style={style}>
|
||||
<CharacterString>{loopText(text)}</CharacterString>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
50
js/marqueeUtils.test.ts
Normal file
50
js/marqueeUtils.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
45
js/marqueeUtils.tsx
Normal file
45
js/marqueeUtils.tsx
Normal file
|
|
@ -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`;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue