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.min.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; /** * 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; // TODO: Type JSZip requireMusicMetadata(): Promise; // 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 => 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 { // 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( , 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;