Implement VerticalScrollbar

This gets rid of rc-slider which was way too heavy, was out of date and I never quiet trusted.

We only ever used a portion of it any way.

Also, this sets us up to have more direct control over the sliders which we will need if we want to support the behavior where you can click once and drag across the EQ sliders to draw a line.
This commit is contained in:
Jordan Eldredge 2020-12-23 17:45:48 -08:00
parent bb8de18a73
commit cfeef49ba7
13 changed files with 162 additions and 563 deletions

File diff suppressed because one or more lines are too long

View file

@ -195,20 +195,3 @@
top: 38px;
}
#webamp .band {
width: 14px;
height: 63px;
}
#webamp .band .rc-slider {
height: 51px;
width: 14px;
margin-top: 6px;
}
#webamp .band .rc-slider .rc-slider-handle {
width: 11px;
height: 11px;
margin: -6px 0 0 1px;
position: absolute;
}

View file

@ -102,17 +102,6 @@
padding-bottom: 18px;
}
#webamp .playlist-scrollbar {
height: 100%;
width: 8px;
margin-left: 5px;
}
#webamp .playlist-scrollbar-handle {
width: 8x;
height: 18px;
}
#webamp .playlist-bottom {
width: 100%;
height: 38px;

View file

@ -23,8 +23,6 @@ Coordination between the playing media (which is inherently stateful, and change
We use [react-redux] to bind Redux state into our [React components](../js/components/). Any component which is not reusable, and even some that are, are connected using [connect]. This works well for us, since most components are "one off", and connecting most components allows for state changes to result in a mininmal set of react components needing to re-render. This does, however, require that our selectors be performant.
In some places we use [rc-slider](https://github.com/react-component/slider) instead of `<input type='range'>` since it gives us more precise control over styling. This is particualarly true for the vertial sliders in the equalizer window. As far as I can tell, there is no way to style the handle of a vertical `<input type='range'>` in a way that gives pixel-level accuracy.
## Media
Media (audio files) is managed by our [Media](../js/media/index.ts) class. It encapsulates the Web Audio API complexity. Audio manipulation (volume, balance, EQ) is handled in the main `Media` class, but the audio source is managed in [elementSource.ts](../js/media/elementSource.ts). This class tries to encapsulate some of the complexity required to get the playing of audio files working across all browsers seamlessly. We handle playing audio from a URL source (subject to CORs) and from a local file. Both of these are normalized to a URL before they are passed into our audio aparatus. For local files, we convert the `ArrayBuffer` we get, into a Blob url using `URL.createObjectURL()`. This transformation is handlded inside our Action Creators (see the Redux section above).

View file

@ -1,9 +1,9 @@
import { useMemo } from "react";
import Slider from "rc-slider";
import { Slider as SliderType } from "../../types";
import { useTypedSelector, useActionCreator } from "../../hooks";
import * as Selectors from "../../selectors";
import * as Actions from "../../actionCreators";
import VerticalSlider from "../VerticalSlider";
interface Props {
id: string;
@ -12,9 +12,10 @@ interface Props {
}
const MAX_VALUE = 100;
// Given a value between 1-100, return the sprite number (0-27)
export const spriteNumber = (value: number): number => {
const percent = value / 100;
const percent = value / MAX_VALUE;
return Math.round(percent * 27);
};
@ -25,7 +26,10 @@ export const spriteOffsets = (number: number): { x: number; y: number } => {
return { x, y };
};
const Handle = () => <div className="rc-slider-handle" />;
const Handle = () => {
const style = { width: 11, height: 11, marginLeft: 1 };
return <div style={style} className="slider-handle" />;
};
export default function Band({ id, onChange, band }: Props) {
const sliders = useTypedSelector(Selectors.getSliders);
@ -39,20 +43,17 @@ export default function Band({ id, onChange, band }: Props) {
const focusBand = useActionCreator(Actions.focusBand);
const usetFocus = useActionCreator(Actions.unsetFocus);
const handleMouseDown = () => focusBand(band);
return (
<div id={id} className="band" style={{ backgroundPosition }}>
<Slider
min={0}
max={MAX_VALUE}
step={1}
value={MAX_VALUE - value}
vertical
onChange={onChange}
onBeforeChange={handleMouseDown}
<VerticalSlider
height={62}
width={14}
handleHeight={11}
value={1 - value / MAX_VALUE}
onBeforeChange={() => focusBand(band)}
onChange={(val) => onChange((1 - val) * MAX_VALUE)}
onAfterChange={usetFocus}
handle={Handle}
handle={<Handle />}
/>
</div>
);

View file

@ -0,0 +1,42 @@
import VerticalSlider from "../VerticalSlider";
import * as Selectors from "../../selectors";
import * as Actions from "../../actionCreators";
import { useTypedSelector, useActionCreator } from "../../hooks";
import { WINDOWS } from "../../constants";
const HANDLE_HEIGHT = 18;
const Handle = () => (
<div
className="playlist-scrollbar-handle"
style={{ height: HANDLE_HEIGHT }}
/>
);
export default function PlaylistScrollBar() {
const getWindowPixelSize = useTypedSelector(Selectors.getWindowPixelSize);
const playlistHeight = getWindowPixelSize(WINDOWS.PLAYLIST).height;
const playlistScrollPosition = useTypedSelector(
Selectors.getPlaylistScrollPosition
);
const allTracksAreVisible = useTypedSelector(
Selectors.getAllTracksAreVisible
);
const setPlaylistScrollPosition = useActionCreator(
Actions.setPlaylistScrollPosition
);
return (
<div className="playlist-scrollbar" style={{ marginLeft: 5 }}>
<VerticalSlider
height={playlistHeight - 58}
handleHeight={HANDLE_HEIGHT}
width={8}
value={playlistScrollPosition / 100}
onChange={(val) => setPlaylistScrollPosition(val * 100)}
handle={<Handle />}
disabled={allTracksAreVisible}
/>
</div>
);
}

View file

@ -1,42 +0,0 @@
import { memo } from "react";
// Here we import the rc-slider class just to get it's type.
// We expect the Typescript compiler to not actually include this in the bundle.
import RcSlider from "rc-slider";
// @ts-ignore
import SliderComponent from "rc-slider/lib/Slider";
import {
getAllTracksAreVisible,
getPlaylistScrollPosition,
} from "../../selectors";
import * as Actions from "../../actionCreators";
import { useTypedSelector, useActionCreator } from "../../hooks";
// Here we inform TypeScript to use the default export's type for our partial import.
const Slider = SliderComponent as typeof RcSlider;
const Handle = () => <div className="playlist-scrollbar-handle" />;
const ScrollBar = () => {
const playlistScrollPosition = useTypedSelector(getPlaylistScrollPosition);
const allTracksAreVisible = useTypedSelector(getAllTracksAreVisible);
const setPlaylistScrollPosition = useActionCreator((position: number) =>
Actions.setPlaylistScrollPosition(100 - position)
);
return (
<Slider
className="playlist-scrollbar"
min={0}
max={100}
step={1}
value={playlistScrollPosition}
onChange={setPlaylistScrollPosition}
vertical
handle={Handle}
disabled={allTracksAreVisible}
/>
);
};
export default memo(ScrollBar);

View file

@ -1,436 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PlaylistWindow renders to snapshot 1`] = `
<div
onFocus={[Function]}
onMouseDown={[Function]}
style={
Object {
"height": "100%",
"width": "100%",
}
}
tabIndex={-1}
>
<div
className="window draggable"
id="playlist-window"
onDragEnter={[Function]}
onDragOver={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
onWheel={[Function]}
style={
Object {
"backgroundColor": "#000000",
"color": "#00FF00",
"fontFamily": "Arial, Arial, sans-serif",
"height": "116px",
"width": "275px",
}
}
>
<div
className="playlist-top draggable"
onDoubleClick={[Function]}
>
<div
className="playlist-top-left draggable"
/>
<div
className="playlist-top-left-spacer draggable"
/>
<div
className="playlist-top-left-fill draggable"
/>
<div
className="playlist-top-title draggable"
/>
<div
className="playlist-top-right-spacer draggable"
/>
<div
className="playlist-top-right-fill draggable"
/>
<div
className="playlist-top-right draggable"
>
<div
id="playlist-shade-button"
onClick={[Function]}
/>
<div
id="playlist-close-button"
onClick={[Function]}
/>
</div>
</div>
<div
className="playlist-middle draggable"
>
<div
className="playlist-middle-left draggable"
/>
<div
className="playlist-middle-center"
>
<div
className="playlist-tracks"
onClick={[Function]}
onWheel={[Function]}
style={
Object {
"height": "100%",
}
}
>
<div
className="playlist-track-titles"
/>
<div
className="playlist-track-durations"
/>
</div>
</div>
<div
className="playlist-middle-right draggable"
>
<div
className="rc-slider rc-slider-disabled rc-slider-vertical playlist-scrollbar"
onBlur={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
onMouseUp={[Function]}
onTouchStart={[Function]}
>
<div
className="rc-slider-rail"
style={Object {}}
/>
<div
className="rc-slider-track"
style={
Object {
"bottom": "0%",
"height": "0%",
"top": "auto",
}
}
/>
<div
className="rc-slider-step"
/>
<div
className="playlist-scrollbar-handle"
/>
<div
className="rc-slider-mark"
/>
</div>
</div>
</div>
<div
className="playlist-bottom draggable"
>
<div
className="playlist-bottom-left draggable"
>
<div
className="playlist-menu"
id="playlist-add-menu"
onClick={[Function]}
>
<div
className="bar"
/>
</div>
<div
className="playlist-menu"
id="playlist-remove-menu"
onClick={[Function]}
>
<div
className="bar"
/>
</div>
<div
className="playlist-menu"
id="playlist-selection-menu"
onClick={[Function]}
>
<div
className="bar"
/>
</div>
<div
className="playlist-menu"
id="playlist-misc-menu"
onClick={[Function]}
>
<div
className="bar"
/>
</div>
</div>
<div
className="playlist-bottom-center draggable"
/>
<div
className="playlist-bottom-right draggable"
>
<div
className="playlist-running-time-display draggable"
>
<div>
<span
className=" character character-48"
>
0
</span>
<span
className=" character character-58"
>
:
</span>
<span
className=" character character-48"
>
0
</span>
<span
className=" character character-48"
>
0
</span>
<span
className=" character character-47"
>
/
</span>
<span
className=" character character-48"
>
0
</span>
<span
className=" character character-58"
>
:
</span>
<span
className=" character character-48"
>
0
</span>
<span
className=" character character-48"
>
0
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
<span
className=" character character-32"
>
</span>
</div>
</div>
<div
className="playlist-action-buttons"
>
<div
className="playlist-previous-button"
onClick={[Function]}
/>
<div
className="playlist-play-button"
onClick={[Function]}
/>
<div
className="playlist-pause-button"
onClick={[Function]}
/>
<div
className="playlist-stop-button"
onClick={[Function]}
/>
<div
className="playlist-next-button"
onClick={[Function]}
/>
<div
className="playlist-eject-button"
onClick={[Function]}
/>
</div>
<div
className="mini-time countdown"
onClick={[Function]}
>
<span
className="background-character character character-32"
style={
Object {
"left": 1,
}
}
>
</span>
<span
className="background-character character character-32"
style={
Object {
"left": 7,
}
}
>
</span>
<span
className="background-character character character-32"
style={
Object {
"left": 12,
}
}
>
</span>
<span
className="background-character character character-32"
style={
Object {
"left": 20,
}
}
>
</span>
<span
className="background-character character character-32"
style={
Object {
"left": 25,
}
}
>
</span>
<span
className=" character character-32"
style={
Object {
"left": 1,
}
}
>
</span>
<span
className=" character character-32"
style={
Object {
"left": 7,
}
}
>
</span>
<span
className=" character character-32"
style={
Object {
"left": 12,
}
}
>
</span>
<span
className=" character character-32"
style={
Object {
"left": 20,
}
}
>
</span>
<span
className=" character character-32"
style={
Object {
"left": 25,
}
}
>
</span>
</div>
<div
className="playlist-menu"
id="playlist-list-menu"
onClick={[Function]}
>
<div
className="bar"
/>
</div>
<div
id="playlist-scroll-up-button"
onClick={[Function]}
/>
<div
id="playlist-scroll-down-button"
onClick={[Function]}
/>
<div
id="playlist-resize-target"
onMouseDown={[Function]}
/>
</div>
</div>
</div>
</div>
`;

View file

@ -1,31 +0,0 @@
import { Provider } from "react-redux";
import renderer from "react-test-renderer";
import getStore from "../../store";
import PlaylistWindow from "./index";
const media = {
addEventListener: jest.fn(),
setVolume: jest.fn(),
setPreamp: jest.fn(),
setBalance: jest.fn(),
getAnalyser: () => null,
on: jest.fn(),
};
describe("PlaylistWindow", () => {
let store;
beforeEach(() => {
store = getStore(media);
});
it("renders to snapshot", () => {
const tree = renderer
.create(
<Provider store={store}>
<PlaylistWindow />
</Provider>
)
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View file

@ -17,7 +17,7 @@ import ListMenu from "./ListMenu";
import PlaylistResizeTarget from "./PlaylistResizeTarget";
import PlaylistActionArea from "./PlaylistActionArea";
import TrackList from "./TrackList";
import ScrollBar from "./ScrollBar";
import PlaylistScrollBar from "./PlaylistScrollBar";
import "../../../css/playlist-window.css";
import { AppState } from "../../types";
@ -124,7 +124,7 @@ function PlaylistWindow({ analyser }: Props) {
<TrackList />
</div>
<div className="playlist-middle-right draggable">
<ScrollBar />
<PlaylistScrollBar />
</div>
</div>
<div className="playlist-bottom draggable">

View file

@ -0,0 +1,97 @@
import * as Utils from "../utils";
import { ReactNode, useRef } from "react";
type Props = {
height: number;
width: number;
handleHeight: number;
value: number;
handle: ReactNode;
onBeforeChange?: () => void;
onChange: (value: number) => void;
onAfterChange?: () => void;
disabled?: boolean;
};
// `<input type="range" />` can be rotated to become a vertical slider using
// CSS, but that makes it impossible to style the handle in a pixel perfect
// manner. Instead we reimplement it in React.
export default function VerticalSlider({
value,
height,
width,
handle,
handleHeight,
onBeforeChange,
onChange,
onAfterChange,
disabled,
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const handleRef = useRef<HTMLDivElement | null>(null);
const offset = (height - handleHeight) * value;
function handleMouseDown(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const sliderNode = ref.current;
const handleNode = handleRef.current;
if (sliderNode == null || handleNode == null) {
// I don't think this could ever happen in practice
return null;
}
const sliderRect = sliderNode.getBoundingClientRect();
const handleRect = handleNode.getBoundingClientRect();
const { top: sliderTop, height: sliderHeight } = sliderRect;
const { top: handleTop, height: realHandleHeight } = handleRect;
// If the user clicks on the handle we want them to continue to hold onto
// that point of te handle. If they click outside of the handle (in the
// slider itself) we want to center the handle at that point and have them
// move the handle from the center.
const handleOffset = handleNode.contains(e.target as HTMLElement)
? e.clientY - handleTop
: realHandleHeight / 2;
const baseOffset = sliderTop + handleOffset;
// Measure the actual rect height rather than use the `height` prop, becuase
// we might be in double-size mode.
const spanSize = sliderHeight - realHandleHeight;
function moveToCursor(event: MouseEvent) {
// Ensure dragging does not cause elements/text to be selected.
// https://stackoverflow.com/a/19164149/1263117
event.preventDefault();
const startOffset = event.clientY - baseOffset;
onChange(Utils.clamp(startOffset / spanSize, 0, 1));
}
function handleMouseUp() {
if (onAfterChange != null) {
onAfterChange();
}
document.removeEventListener("mousemove", moveToCursor);
document.removeEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousemove", moveToCursor);
document.addEventListener("mouseup", handleMouseUp);
if (onBeforeChange != null) {
onBeforeChange();
}
// Move the slider to where they've started.
moveToCursor(e.nativeEvent);
}
return (
<div
style={{ height, width }}
onMouseDown={disabled ? undefined : handleMouseDown}
ref={ref}
>
<div style={{ transform: `translateY(${offset}px)` }} ref={handleRef}>
{handle}
</div>
</div>
);
}

View file

@ -155,10 +155,10 @@ export const imageSelectors: Selectors = {
EQ_TITLE_BAR: [".equalizer-top"],
EQ_TITLE_BAR_SELECTED: [".selected .equalizer-top"],
EQ_SLIDER_BACKGROUND: [".band"],
EQ_SLIDER_THUMB: [".band .rc-slider-handle"],
EQ_SLIDER_THUMB: [".band .slider-handle"],
// But the "active" pseudo selector on the parent, since clicking
// anywhere on the track moves the slider.
EQ_SLIDER_THUMB_SELECTED: [".band .rc-slider:active .rc-slider-handle"],
EQ_SLIDER_THUMB_SELECTED: [".band:active .slider-handle"],
EQ_ON_BUTTON: ["#on"],
EQ_ON_BUTTON_DEPRESSED: ["#on:active"],
EQ_ON_BUTTON_SELECTED: ["#on.selected"],
@ -349,7 +349,7 @@ export const cursorSelectors: Selectors = {
CLOSE: ["#title-bar #close"],
// This is not quite right. There are some areas that show this cursor
// but are not clickable.
EQSLID: ["#equalizer-window .band .rc-slider"],
EQSLID: ["#equalizer-window .band"],
EQNORMAL: ["#equalizer-window"],
EQCLOSE: ["#equalizer-window #equalizer-close"],
EQTITLE: [

View file

@ -73,7 +73,6 @@
"@types/jszip": "^3.1.5",
"@types/lodash": "^4.14.116",
"@types/lodash-es": "^4.17.1",
"@types/rc-slider": "^8.6.3",
"@types/react": "^16.8.13",
"@types/react-dom": "^16.8.4",
"@types/react-redux": "^7.1.1",
@ -116,7 +115,6 @@
"postcss-loader": "^3.0.0",
"prettier": "^2.0.4",
"puppeteer": "^1.15.0",
"rc-slider": "^8.7.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-redux": "^7.2.2",