webamp/docs/architecture.md
2018-04-14 16:10:15 -07:00

9.9 KiB

Architecture

Library/Application

This repository contains both an NPM module, and a demo page, found at https://webamp.org. The NPM module's goal is to provide a widget which can be embedded in any website, where as the demo page depends upon the library, and provides the canonical usage. You can find more information about the library's API in the usage document.

  • The entry point for the demo page is index.js
  • The entry point for the NPM modules is webamp.js

Redux

Within the core library, state is managed by Redux. In fact, Redux's own docs mention Webamp in the Apps and Examples section of their docs!

Our reducers, and the states they control, can be found in the reducers directory.

Async actions are handled using the redux-thunk. You can find all of the actionCreators in the actionCreators.js file. This is the most complex portion of the app, as it contains all the coordination of our many async actions.

Any non-trivial value derived from state is computed inside a selector. Care has been taken to ensure that the structure of the state allows for most common selectors to be a constant time operation. For selectors that are O(n), we use reselect to ensure the calculation is only done when dependent values change.

Coordination between the playing media (which is inherently stateful, and changes over time) and Redux is handed in the mediaMiddleware. This basically listens for events, and triggers the correct methods on our Media instance. It also listens events emitted by the media instance, and dispatches the corresponding actions.

React

We use [react-redux] to bind Redux state into our React 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 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 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.js. 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).

The Winamp class instantiates a single Media instance and passes it's AnalyserNode down through the tree of React components to the visualizer.

Skins

Winamp skins (.wsz) are, under the hood, just .zip files which contain .bmp sprite sheets, and some config files. We use JSZip to unpack the zip archive, and then some clever canvas slicing to extract the individual sprites out of each .bmp. We get these sprite images as data URIs which are dispatched to Redux. We then have a Skin component which renders a <style> tag into the <head> using React Portals. You can read more about this in a blog post I wrote.

In addition to the sprite sheets there are some config files in various formats, mostly .ini (although with different names). We have a very simple .ini parser, for these files. Finally, there is a region.txt which defines some vectors which define the transparent areas of a skin. We have a custom parser for this file format, and then dispatch the data to Redux.

From there, our Skin component has a ClipPaths sub component which ouputs a series of <svg>s representing the clip path for each window into the DOM. Finally, CSS clip-path rules representing each window are dynamically generated and added to the injected style sheet.

The parsing of skin files is handed in skinParser.js. Rendering the <svg> and <style> tags is done in Skin.js. The definitions for all the individual sprites live in skinSpirtes.js, and the mapping of skin spirtes to CSS rules lives in skinSelectors.js.

CSS

CSS style sheets are imported by the components which use them. Each rule is individually prefixed with the id #webamp to prevent our style rules from "leaking" out onto other potential elements. For example, 98.js.org, includes Webamp on a page along with many other elements, and we want to be sure our rules for .selected elements do not apply to any elements outside of #webamp.

The nature of Winamp skins is such that most elements are absolutely positioned, and have an explicit size. There are a few down sides to our current approach:

  1. Much of the size data for the individual sprites is duplicated in the items CSS rules and in skinSprites.js.
  2. Style data is lives far away from the individual component, and it can be hard to know where to look for the CSS that corresponds to an individual component.

All windows are rendred with image-rendering: pixelated; (or equivilant) so that high density displays don't blur the pixel art UI. This is especially important for "Double" mode, where the main and equalizer windows are twice as large. This doubleing is achived with the CSS: transform: scale(2).

Visualizer

The visualizer in the main window is a React component Visualizer.js that gets passed skin data from the Redux store, an analyser node and some various user settings (which visualizer should be shown? are we playing?). All computation is done such that it works both in the "regular" mode, but also in shade mode. In shade mode the dimension values are just different.

To improve performance, two off-screen canvases are pre-rendered whenever the skin changes. These are then used as components of the per-frame rendering:

  • The textured background.
  • A single vertical bar for the bar graph analyser.

The actual canvas is rendered at twice the visible size so that "high density" or "retina" displays will not look blury.

Equalizer

The audio portion of the equalizer is handed in the Media class (see above) but the visual representation of the current equalizer settings is created using cardinal-spline-js which calculates the curved line for us.

The coloring of the curved line is achived by extracting the single-pixel-wide, many-pixel-tall sprite from the skin file, passing it through the Redux state to the graph component, and then drawing the splined line with CanvasRenderingContext2D.createPattern() .

You can see the implementation of the equalizer graph in EqGraph.js.

The equalizer window can also load/geneate .eqf files. The loading and parsing of these has been extracted into its own NPM module: winamp-eqf.

Playlist

TODO

  • Windowing
  • Resizing
  • Menus

Window management

Window position is managed in the WindowManager.js React component. It handles:

  • Dragging windows around (windows can add a .draggable class to any node to make it a drag handle)
  • Snapping windows to eachother
  • Snapping windows to the browser edges
  • Creating the initial layout of the windows

Moving windows when their neighbors are resized via "double" or "shade" mode toggles is handled by the withWindowGraphIntegrity() higher order action creator in actionCreators.js. It works by:

  1. Computing a graph where each window is a node, and the edges are where those windows are "touching".
  2. Dispatching the passed in action.
  3. Looking at the new sizes, and dispatching another event to move the windows to new locations where the original edeges are all still touching.

Dropping files

Webamp allows you to drag files (media files, skins and .eqf equalizer presets) onto various windows to load them. This behavior is manged by the <DropTarget> component. The component expects to be passed a handleDrop handler which will be called with the drop event, and also the {x, y} coordinates of the drop within the component. This is needed to allow us to insert dropped tracks at the correct place in the playlist.