mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-24 02:36:00 +00:00
390 lines
10 KiB
TypeScript
390 lines
10 KiB
TypeScript
import React from "react";
|
|
import ReactDOM from "react-dom";
|
|
import { Provider } from "react-redux";
|
|
|
|
import {
|
|
Store,
|
|
AppState,
|
|
Track,
|
|
LoadedURLTrack,
|
|
Middleware,
|
|
WindowPosition
|
|
} from "./types";
|
|
import getStore from "./store";
|
|
import App from "./components/App";
|
|
import { bindHotkeys } from "./hotkeys";
|
|
import Media from "./media";
|
|
import * as Selectors from "./selectors";
|
|
import * as Actions from "./actionCreators";
|
|
|
|
import { LOAD_STYLE } from "./constants";
|
|
import * as Utils from "./utils";
|
|
|
|
import {
|
|
SET_AVAILABLE_SKINS,
|
|
NETWORK_CONNECTED,
|
|
NETWORK_DISCONNECTED,
|
|
CLOSE_WINAMP,
|
|
MINIMIZE_WINAMP,
|
|
LOADED,
|
|
SET_Z_INDEX,
|
|
CLOSE_REQUESTED,
|
|
ENABLE_MEDIA_LIBRARY,
|
|
ENABLE_MILKDROP
|
|
} from "./actionTypes";
|
|
import Emitter from "./emitter";
|
|
|
|
import "../css/base-skin.css";
|
|
import { SerializedStateV1 } from "./serializedStates/v1Types";
|
|
import Disposable from "./Disposable";
|
|
|
|
interface Options {
|
|
/**
|
|
* An object representing the initial skin to use.
|
|
*
|
|
* If omitted, the default skin, included in the bundle, will be used.
|
|
* Note: This URL must be served the with correct CORs headers.
|
|
*
|
|
* Example: `{ url: './path/to/skin.wsz' }`
|
|
*/
|
|
initialSkin?: {
|
|
url: string;
|
|
};
|
|
|
|
/**
|
|
* An array of `Track`s to prepopulate the playlist with.
|
|
*/
|
|
initialTracks?: Track[];
|
|
|
|
/**
|
|
* An array of objects representing available skins.
|
|
*
|
|
* These will appear in the "Options" menu under "Skins".
|
|
* Note: These URLs must be served with the correct CORs headers.
|
|
*
|
|
* Example: `[ { url: "./green.wsz", name: "Green Dimension V2" } ]`
|
|
*/
|
|
availableSkins?: { url: string; name: string }[];
|
|
|
|
/**
|
|
* Should global hotkeys be enabled?
|
|
*
|
|
* Default: `false`
|
|
*/
|
|
enableHotkeys?: boolean;
|
|
|
|
/**
|
|
* An array of additional file pickers.
|
|
*
|
|
* These will appear in the "Options" menu under "Play".
|
|
*
|
|
* In the offical version, this option is used to provide a "Dropbox" file picker.
|
|
*/
|
|
filePickers?: [
|
|
{
|
|
/**
|
|
* The name that will appear in the context menu.
|
|
*
|
|
* Example: `"My File Picker..."`
|
|
*/
|
|
contextMenuName: string;
|
|
|
|
/**
|
|
* A function which returns a Promise that resolves to an array of `Track`s
|
|
*
|
|
* Example: `() => Promise.resolve([{ url: './rick_roll.mp3' }])`
|
|
*/
|
|
filePicker: () => Promise<Track[]>;
|
|
|
|
/**
|
|
* Indicates if this options should be made available when the user is offline.
|
|
*/
|
|
requiresNetwork: boolean;
|
|
}
|
|
];
|
|
zIndex: number;
|
|
}
|
|
|
|
interface PrivateOptions {
|
|
avaliableSkins?: { url: string; name: string }[]; // Old misspelled name
|
|
requireJSZip(): Promise<never>; // TODO: Type JSZip
|
|
requireMusicMetadata(): Promise<any>; // TODO: Type musicmetadata
|
|
__initialState?: AppState;
|
|
__customMiddlewares?: Middleware[];
|
|
__enableMediaLibrary?: boolean;
|
|
__initialWindowLayout: {
|
|
[windowId: string]: {
|
|
size: null | [number, number];
|
|
position: WindowPosition;
|
|
};
|
|
};
|
|
__butterchurnOptions: { butterchurnOpen: boolean };
|
|
}
|
|
|
|
// Return a promise that resolves when the store matches a predicate.
|
|
const storeHas = (
|
|
store: Store,
|
|
predicate: (state: AppState) => boolean
|
|
): Promise<void> =>
|
|
new Promise(resolve => {
|
|
if (predicate(store.getState())) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const unsubscribe = store.subscribe(() => {
|
|
if (predicate(store.getState())) {
|
|
resolve();
|
|
unsubscribe();
|
|
}
|
|
});
|
|
});
|
|
|
|
class Winamp {
|
|
_actionEmitter: Emitter;
|
|
_node: HTMLElement | null;
|
|
_disposable: Disposable;
|
|
options: Options & PrivateOptions; // TODO: Make this _private
|
|
media: Media; // TODO: Make this _private
|
|
store: Store; // TODO: Make this _private
|
|
static browserIsSupported() {
|
|
const supportsAudioApi = !!(
|
|
window.AudioContext ||
|
|
// @ts-ignore
|
|
window.webkitAudioContext
|
|
);
|
|
const supportsCanvas = !!window.document.createElement("canvas").getContext;
|
|
const supportsPromises = typeof Promise !== "undefined";
|
|
return supportsAudioApi && supportsCanvas && supportsPromises;
|
|
}
|
|
|
|
constructor(options: Options & PrivateOptions) {
|
|
this._node = null;
|
|
this._disposable = new Disposable();
|
|
this._actionEmitter = new Emitter();
|
|
this.options = options;
|
|
const {
|
|
initialTracks,
|
|
initialSkin,
|
|
avaliableSkins, // Old misspelled name
|
|
availableSkins,
|
|
enableHotkeys = false,
|
|
zIndex,
|
|
requireJSZip,
|
|
requireMusicMetadata
|
|
} = this.options;
|
|
|
|
// TODO: Validate required options
|
|
|
|
this.media = new Media();
|
|
this.store = getStore(
|
|
this.media,
|
|
this._actionEmitter,
|
|
this.options.__customMiddlewares,
|
|
this.options.__initialState,
|
|
{ requireJSZip, requireMusicMetadata }
|
|
) as Store;
|
|
|
|
if (navigator.onLine) {
|
|
this.store.dispatch({ type: NETWORK_CONNECTED });
|
|
} else {
|
|
this.store.dispatch({ type: NETWORK_DISCONNECTED });
|
|
}
|
|
|
|
if (zIndex != null) {
|
|
this.store.dispatch({ type: SET_Z_INDEX, zIndex });
|
|
}
|
|
|
|
if (options.__butterchurnOptions) {
|
|
this.store.dispatch({
|
|
type: ENABLE_MILKDROP,
|
|
open: options.__butterchurnOptions.butterchurnOpen
|
|
});
|
|
}
|
|
|
|
if (options.__enableMediaLibrary) {
|
|
this.store.dispatch({ type: ENABLE_MEDIA_LIBRARY });
|
|
}
|
|
|
|
const handleOnline = () => this.store.dispatch({ type: NETWORK_CONNECTED });
|
|
const handleOffline = () =>
|
|
this.store.dispatch({ type: NETWORK_DISCONNECTED });
|
|
|
|
window.addEventListener("online", handleOnline);
|
|
window.addEventListener("offline", handleOffline);
|
|
|
|
this._disposable.add(() => {
|
|
window.removeEventListener("online", handleOnline);
|
|
window.removeEventListener("offline", handleOffline);
|
|
});
|
|
|
|
if (initialSkin) {
|
|
this.store.dispatch(Actions.setSkinFromUrl(initialSkin.url));
|
|
} else {
|
|
// We are using the default skin.
|
|
this.store.dispatch({ type: LOADED });
|
|
}
|
|
|
|
if (initialTracks) {
|
|
this._bufferTracks(initialTracks);
|
|
}
|
|
|
|
if (avaliableSkins != null) {
|
|
console.warn(
|
|
"The misspelled option `avaliableSkins` is deprecated. Please use `availableSkins` instead."
|
|
);
|
|
this.store.dispatch({ type: SET_AVAILABLE_SKINS, skins: avaliableSkins });
|
|
} else if (availableSkins != null) {
|
|
this.store.dispatch({ type: SET_AVAILABLE_SKINS, skins: availableSkins });
|
|
}
|
|
|
|
const layout = options.__initialWindowLayout;
|
|
if (layout == null) {
|
|
this.store.dispatch(Actions.stackWindows());
|
|
} else {
|
|
Utils.objectForEach(layout, (w, windowId) => {
|
|
if (w.size != null) {
|
|
this.store.dispatch(Actions.setWindowSize(windowId, w.size));
|
|
}
|
|
});
|
|
this.store.dispatch(
|
|
Actions.updateWindowPositions(
|
|
Utils.objectMap(layout, w => w.position),
|
|
false
|
|
)
|
|
);
|
|
}
|
|
|
|
if (enableHotkeys) {
|
|
this._disposable.add(bindHotkeys(this.store.dispatch));
|
|
}
|
|
}
|
|
|
|
play() {
|
|
this.store.dispatch(Actions.play());
|
|
}
|
|
|
|
pause() {
|
|
this.store.dispatch(Actions.pause());
|
|
}
|
|
|
|
seekBackward(seconds: number) {
|
|
this.store.dispatch(Actions.seekBackward(seconds));
|
|
}
|
|
|
|
seekForward(seconds: number) {
|
|
this.store.dispatch(Actions.seekForward(seconds));
|
|
}
|
|
|
|
nextTrack() {
|
|
this.store.dispatch(Actions.next());
|
|
}
|
|
|
|
previousTrack() {
|
|
this.store.dispatch(Actions.previous());
|
|
}
|
|
|
|
_bufferTracks(tracks: Track[]) {
|
|
const nextIndex = Selectors.getTrackCount(this.store.getState());
|
|
this.store.dispatch(
|
|
Actions.loadMediaFiles(tracks, LOAD_STYLE.BUFFER, nextIndex)
|
|
);
|
|
}
|
|
|
|
// Append this array of tracks to the end of the current playlist.
|
|
appendTracks(tracks: Track[]) {
|
|
const nextIndex = Selectors.getTrackCount(this.store.getState());
|
|
this.store.dispatch(
|
|
Actions.loadMediaFiles(tracks, LOAD_STYLE.NONE, nextIndex)
|
|
);
|
|
}
|
|
|
|
// Replace any existing tracks with this array of tracks, and begin playing.
|
|
setTracksToPlay(tracks: Track[]) {
|
|
this.store.dispatch(Actions.loadMediaFiles(tracks, LOAD_STYLE.PLAY));
|
|
}
|
|
|
|
onWillClose(cb: (cancel: () => void) => void): () => void {
|
|
return this._actionEmitter.on(CLOSE_REQUESTED, action => {
|
|
cb(action.cancel);
|
|
});
|
|
}
|
|
|
|
onClose(cb: () => void): () => void {
|
|
return this._actionEmitter.on(CLOSE_WINAMP, cb);
|
|
}
|
|
|
|
reopen(): void {
|
|
this.store.dispatch(Actions.open());
|
|
}
|
|
|
|
onTrackDidChange(cb: (trackInfo: LoadedURLTrack | null) => void): () => void {
|
|
let previousTrackId: number | null = null;
|
|
return this.store.subscribe(() => {
|
|
const state = this.store.getState();
|
|
const trackId = Selectors.getCurrentlyPlayingTrackIdIfLoaded(state);
|
|
if (trackId === previousTrackId) {
|
|
return;
|
|
}
|
|
previousTrackId = trackId;
|
|
cb(trackId == null ? null : Selectors.getCurrentTrackInfo(state));
|
|
});
|
|
}
|
|
|
|
onMinimize(cb: () => void): () => void {
|
|
return this._actionEmitter.on(MINIMIZE_WINAMP, cb);
|
|
}
|
|
|
|
async skinIsLoaded(): Promise<void> {
|
|
// Wait for the skin to load.
|
|
return storeHas(this.store, state => !state.display.loading);
|
|
}
|
|
|
|
__loadSerializedState(serializedState: SerializedStateV1): void {
|
|
this.store.dispatch(Actions.loadSerializedState(serializedState));
|
|
}
|
|
|
|
__getSerializedState() {
|
|
return Selectors.getSerlializedState(this.store.getState());
|
|
}
|
|
|
|
__onStateChange(cb: () => void): () => void {
|
|
return this.store.subscribe(cb);
|
|
}
|
|
|
|
async renderWhenReady(node: HTMLElement) {
|
|
this.store.dispatch(Actions.centerWindowsInContainer(node));
|
|
await this.skinIsLoaded();
|
|
if (this._node != null) {
|
|
throw new Error("Cannot render a Webamp instance twice");
|
|
}
|
|
this._node = node;
|
|
this._disposable.add(() => {
|
|
if (this._node != null) {
|
|
ReactDOM.unmountComponentAtNode(this._node);
|
|
this._node = null;
|
|
}
|
|
});
|
|
|
|
ReactDOM.render(
|
|
<Provider store={this.store}>
|
|
<App
|
|
media={this.media}
|
|
container={node}
|
|
filePickers={this.options.filePickers}
|
|
butterchurnOptions={this.options.__butterchurnOptions}
|
|
/>
|
|
</Provider>,
|
|
node
|
|
);
|
|
}
|
|
|
|
dispose() {
|
|
// TODO: Clean up store subscription in onTrackDidChange
|
|
// TODO: Every storeHas call represents a potential race condition
|
|
this.media.dispose();
|
|
this._actionEmitter.dispose();
|
|
this._disposable.dispose();
|
|
}
|
|
}
|
|
|
|
export default Winamp;
|