diff --git a/.eslintignore b/.eslintignore index 8f579e37..2aef4f79 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ built/ coverage/ **/node_modules/ +experiments/winamp-skin-museum/ \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index 088ba4a4..7fb6d974 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,12 +33,7 @@ "camelcase": "error", "consistent-return": "warn", "constructor-super": "error", - "dot-notation": [ - "error", - { - "allowKeywords": false - } - ], + "dot-notation": "error", "eqeqeq": ["error", "smart"], "guard-for-in": "error", "max-depth": ["warn", 4], diff --git a/.gitignore b/.gitignore index c06694f5..e199ab42 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules /built /coverage +/experiments/winamp-skin-museum diff --git a/experiments/automatedScreenshots/index.js b/experiments/automatedScreenshots/index.js index 7c444469..e1a48ef8 100644 --- a/experiments/automatedScreenshots/index.js +++ b/experiments/automatedScreenshots/index.js @@ -116,7 +116,7 @@ const config = { config.skinUrl = skinUrl; const url = `http://localhost:8080/#${JSON.stringify(config)}`; console.log({ url }); - await page["goto"](url); + await page.goto(url); await page.waitForSelector("#main-window", { timeout: 2000 }); console.log("Writing screenshot to", screenshotFile); diff --git a/index.html b/index.html index bb573f08..ec82b657 100755 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ Feedback | GitHub

+ diff --git a/js/actionCreators.js b/js/actionCreators.js index 7fbee50e..204329b3 100644 --- a/js/actionCreators.js +++ b/js/actionCreators.js @@ -1,9 +1,10 @@ -import jsmediatags from "jsmediatags/dist/jsmediatags"; import { parser, creator } from "winamp-eqf"; import { genArrayBufferFromFileReference, genArrayBufferFromUrl, - promptForFileReferences + promptForFileReferences, + genMediaDuration, + genMediaTags } from "./fileUtils"; import skinParser from "./skinParser"; import { BANDS, TRACK_HEIGHT, LOAD_STYLE } from "./constants"; @@ -13,7 +14,9 @@ import { getScrollOffset, getOverflowTrackCount, getPlaylistURL, - getSelectedTrackObjects + getSelectedTrackObjects, + getTracks, + getTrackIsVisibleFunction } from "./selectors"; import { @@ -53,9 +56,22 @@ import { SET_MEDIA_TAGS, SET_MEDIA_DURATION, TOGGLE_SHADE_MODE, - TOGGLE_PLAYLIST_SHADE_MODE + TOGGLE_PLAYLIST_SHADE_MODE, + MEDIA_TAG_REQUEST_INITIALIZED, + MEDIA_TAG_REQUEST_FAILED, + PLAYLIST_SIZE_CHANGED } from "./actionTypes"; +import LoadQueue from "./loadQueue"; + +// Lower is better +const DURATION_VISIBLE_PRIORITY = 5; +const META_DATA_VISIBLE_PRIORITY = 10; +const DURATION_PRIORITY = 15; +const META_DATA_PRIORITY = 20; + +const loadQueue = new LoadQueue({ threads: 4 }); + function playRandomTrack() { return (dispatch, getState) => { const { playlist: { trackOrder, currentTrack } } = getState(); @@ -228,22 +244,23 @@ export function loadFilesFromReferences( } export function fetchMediaDuration(url, id) { - return dispatch => { - // TODO: Does this actually stop downloading the file once it's - // got the duration? - const audio = document.createElement("audio"); - const durationChange = () => { - const { duration } = audio; - dispatch({ type: SET_MEDIA_DURATION, duration, id }); - audio.removeEventListener("durationchange", durationChange); - audio.url = null; - // TODO: Not sure if this really gets cleaned up. - }; - audio.addEventListener("durationchange", durationChange); - audio.addEventListener("error", () => { - // TODO: Should we update the state to indicate that we don't know the length? - }); - audio.src = url; + return (dispatch, getState) => { + loadQueue.push( + () => + genMediaDuration(url) + .then(duration => + dispatch({ type: SET_MEDIA_DURATION, duration, id }) + ) + .catch(() => { + // TODO: Should we update the state to indicate that we don't know the length? + }), + () => { + const trackIsVisible = getTrackIsVisibleFunction(getState()); + return trackIsVisible(id) + ? DURATION_VISIBLE_PRIORITY + : DURATION_PRIORITY; + } + ); }; } @@ -261,7 +278,7 @@ export function loadMediaFiles(tracks, loadStyle = null, atIndex = 0) { } tracks.forEach((track, i) => { const priority = i === 0 && loadStyle != null ? loadStyle : null; - dispatch(loadMediaFile(track, priority, atIndex)); + dispatch(loadMediaFile(track, priority, atIndex + i)); }); }; } @@ -304,35 +321,43 @@ export function loadMediaFile(track, priority = null, atIndex = 0) { if (metaData != null) { const { artist, title } = metaData; dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); + } else if (blob != null) { + // Blobs can be loaded quickly + dispatch(fetchMediaTags(blob, id)); } else { - dispatch(fetchMediaTags(url || blob, id)); + dispatch(queueFetchingMediaTags(id)); } }; } +function queueFetchingMediaTags(id) { + return (dispatch, getState) => { + const track = getTracks(getState())[id]; + return loadQueue.push( + () => dispatch(fetchMediaTags(track.url, id)), + () => { + const trackIsVisible = getTrackIsVisibleFunction(getState()); + return trackIsVisible(track.id) + ? META_DATA_VISIBLE_PRIORITY + : META_DATA_PRIORITY; + } + ); + }; +} + export function fetchMediaTags(file, id) { - // Workaround https://github.com/aadsm/jsmediatags/issues/83 - if (typeof file === "string" && !/^[a-z]+:\/\//i.test(file)) { - file = `${location.protocol}//${location.host}${location.pathname}${file}`; - } return dispatch => { - try { - jsmediatags.read(file, { - onSuccess: data => { - const { artist, title } = data.tags; - // There's more data here, but we don't have a use for it yet: - // https://github.com/aadsm/jsmediatags#shortcuts - dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); - }, - onError: () => { - // Nothing to do. The filename will have to suffice. - } + dispatch({ type: MEDIA_TAG_REQUEST_INITIALIZED, id }); + return genMediaTags(file) + .then(data => { + const { artist, title } = data.tags; + // There's more data here, but we don't have a use for it yet: + // https://github.com/aadsm/jsmediatags#shortcuts + dispatch({ type: SET_MEDIA_TAGS, artist, title, id }); + }) + .catch(() => { + dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id }); }); - } catch (e) { - // Possibly jsmediatags could not find a parser for this file? - // Nothing to do. - // Consider removing this after https://github.com/aadsm/jsmediatags/issues/83 is resolved. - } }; } @@ -367,12 +392,12 @@ export function setSkinFromUrl(url) { }; } -// This function is private, since Winamp consumers may wish to support -// opening files via other means (Dropbox?). Only use the file type specific +// This function is private, since Winamp consumers can provide means for +// opening files via other methods. Only use the file type specific // versions below, since they can defer to the user-defined behavior. function _openFileDialog(accept) { return async dispatch => { - const fileReferences = await promptForFileReferences(accept); + const fileReferences = await promptForFileReferences({ accept }); dispatch(loadFilesFromReferences(fileReferences)); }; } @@ -514,6 +539,10 @@ export function setPlaylistScrollPosition(position) { return { type: SET_PLAYLIST_SCROLL_POSITION, position }; } +export function setPlaylistSize(size) { + return { type: PLAYLIST_SIZE_CHANGED, size }; +} + export function scrollNTracks(n) { return (dispatch, getState) => { const state = getState(); diff --git a/js/actionTypes.js b/js/actionTypes.js index 92d52697..5add53b5 100644 --- a/js/actionTypes.js +++ b/js/actionTypes.js @@ -60,3 +60,5 @@ export const SET_MEDIA_TAGS = "SET_MEDIA_TAGS"; export const SET_MEDIA_DURATION = "SET_MEDIA_DURATION"; export const OPEN_GEN_WINDOW = "OPEN_GEN_WINDOW"; export const CLOSE_GEN_WINDOW = "CLOSE_GEN_WINDOW"; +export const MEDIA_TAG_REQUEST_INITIALIZED = "MEDIA_TAG_REQUEST_INITIALIZED"; +export const MEDIA_TAG_REQUEST_FAILED = "MEDIA_TAG_REQUEST_FAILED"; diff --git a/js/components/App.js b/js/components/App.js index 23b30516..456dc26b 100644 --- a/js/components/App.js +++ b/js/components/App.js @@ -22,13 +22,14 @@ const App = ({ equalizer, playlist, openWindows, - container + container, + filePickers }) => { if (closed) { return null; } const windows = { - main: , + main: , equalizer: equalizer && , playlist: playlist && }; diff --git a/js/components/MainWindow/MainContextMenu.js b/js/components/MainWindow/MainContextMenu.js index f9ff8757..04494dbe 100644 --- a/js/components/MainWindow/MainContextMenu.js +++ b/js/components/MainWindow/MainContextMenu.js @@ -5,8 +5,10 @@ import { close, setSkinFromUrl, openMediaFileDialog, + loadMediaFiles, openSkinFileDialog } from "../../actionCreators"; +import { LOAD_STYLE } from "../../constants"; import { ContextMenu, Hr, Node, Parent, LinkNode } from "../ContextMenu"; const MainContextMenu = props => ( @@ -21,7 +23,19 @@ const MainContextMenu = props => ( label="Winamp2-js" />
- + + + {props.filePickers && + props.filePickers.map((picker, i) => ( + { + props.loadMediaFiles(await picker.filePicker(), LOAD_STYLE.PLAY); + }} + label={picker.contextMenuName} + /> + ))} + {!!props.avaliableSkins.length &&
} @@ -46,6 +60,7 @@ const mapDispatchToProps = { close, openSkinFileDialog, openMediaFileDialog, + loadMediaFiles, setSkin: setSkinFromUrl }; diff --git a/js/components/MainWindow/index.js b/js/components/MainWindow/index.js index 141e7b4f..538d9f61 100644 --- a/js/components/MainWindow/index.js +++ b/js/components/MainWindow/index.js @@ -60,7 +60,8 @@ export class MainWindow extends React.Component { shade, llama, status, - working + working, + filePickers } = this.props; const className = classnames({ @@ -89,7 +90,7 @@ export class MainWindow extends React.Component { className="selected title-bard draggable" onDoubleClick={this.props.toggleMainWindowShadeMode} > - + {shade && } diff --git a/js/components/PlaylistWindow/AddMenu.js b/js/components/PlaylistWindow/AddMenu.js index 4e72e14c..74d4ba3e 100644 --- a/js/components/PlaylistWindow/AddMenu.js +++ b/js/components/PlaylistWindow/AddMenu.js @@ -4,18 +4,22 @@ import { addTracksFromReferences } from "../../actionCreators"; import { promptForFileReferences } from "../../fileUtils"; import PlaylistMenu from "./PlaylistMenu"; +const el = document.createElement("input"); +el.type = "file"; +const DIR_SUPPORT = + typeof el.webkitdirectory !== "undefined" || + typeof el.mozdirectory !== "undefined" || + typeof el.directory !== "undefined"; + /* eslint-disable no-alert */ -const AddMenu = ({ nextIndex, addFilesAtIndex }) => ( +const AddMenu = ({ nextIndex, addFilesAtIndex, addDirAtIndex }) => (
alert("Not supported in Winamp2-js")} /> -
alert("Not supported in Winamp2-js")} - /> +
addDirAtIndex(nextIndex)} />
addFilesAtIndex(nextIndex)} /> ); @@ -28,6 +32,14 @@ const mapDispatchToProps = dispatch => ({ addFilesAtIndex: async nextIndex => { const fileReferences = await promptForFileReferences(); dispatch(addTracksFromReferences(fileReferences, null, nextIndex)); + }, + addDirAtIndex: async nextIndex => { + if (!DIR_SUPPORT) { + alert("Not supported in your browser"); + return; + } + const fileReferences = await promptForFileReferences({ directory: true }); + dispatch(addTracksFromReferences(fileReferences, null, nextIndex)); } }); diff --git a/js/components/PlaylistWindow/PlaylistResizeTarget.js b/js/components/PlaylistWindow/PlaylistResizeTarget.js index 09837ebc..3ab02097 100644 --- a/js/components/PlaylistWindow/PlaylistResizeTarget.js +++ b/js/components/PlaylistWindow/PlaylistResizeTarget.js @@ -1,6 +1,6 @@ import { connect } from "react-redux"; import ResizeTarget from "../ResizeTarget"; -import { PLAYLIST_SIZE_CHANGED } from "../../actionTypes"; +import { setPlaylistSize } from "../../actionCreators"; import { PLAYLIST_RESIZE_SEGMENT_WIDTH, PLAYLIST_RESIZE_SEGMENT_HEIGHT @@ -13,8 +13,6 @@ const mapStateToProps = state => ({ id: "playlist-resize-target" }); -const mapDispatchToProps = { - setPlaylistSize: size => ({ type: PLAYLIST_SIZE_CHANGED, size }) -}; +const mapDispatchToProps = { setPlaylistSize }; export default connect(mapStateToProps, mapDispatchToProps)(ResizeTarget); diff --git a/js/components/PlaylistWindow/index.js b/js/components/PlaylistWindow/index.js index ae725a30..a40ff779 100644 --- a/js/components/PlaylistWindow/index.js +++ b/js/components/PlaylistWindow/index.js @@ -22,6 +22,7 @@ import { togglePlaylistShadeMode, scrollVolume } from "../../actionCreators"; +import { getScrollOffset } from "../../selectors"; import { clamp } from "../../utils"; import DropTarget from "../DropTarget"; @@ -49,7 +50,7 @@ class PlaylistWindow extends React.Component { _handleDrop(e, targetCoords) { const top = e.clientY - targetCoords.y; const atIndex = clamp( - Math.round((top - 23) / TRACK_HEIGHT), + this.props.offset + Math.round((top - 23) / TRACK_HEIGHT), 0, this.props.maxTrackIndex + 1 ); @@ -180,6 +181,7 @@ const mapStateToProps = state => { } = state; return { + offset: getScrollOffset(state), maxTrackIndex: trackOrder.length - 1, focused, skinPlaylistStyle, diff --git a/js/components/WindowManager.js b/js/components/WindowManager.js index 7778fb21..db991f8b 100644 --- a/js/components/WindowManager.js +++ b/js/components/WindowManager.js @@ -166,9 +166,12 @@ class WindowManager extends React.Component { this.setState(stateDiff); }; - window.addEventListener("mouseup", () => { + const removeListeners = () => { window.removeEventListener("mousemove", handleMouseMove); - }); + window.removeEventListener("mouseup", removeListeners); + }; + + window.addEventListener("mouseup", removeListeners); window.addEventListener("mousemove", handleMouseMove); } diff --git a/js/config.js b/js/config.js index f3a942ee..aee45905 100644 --- a/js/config.js +++ b/js/config.js @@ -1,4 +1,5 @@ import skin from "../skins/base-2.91.wsz"; +import llamaAudio from "../mp3/llama-2.91.mp3"; /* global SENTRY_DSN */ @@ -12,9 +13,20 @@ if (hash) { } } +// Backwards compatibility with the old syntax +if (config.audioUrl && !config.initialTracks) { + config.initialTracks = [{ url: config.audioUrl }]; +} + // Turn on the incomplete playlist window export const skinUrl = config.skinUrl === undefined ? skin : config.skinUrl; -export const audioUrl = config.audioUrl; +export const initialTracks = config.initialTracks || [ + { + metaData: { artist: "DJ Mike Llama", title: "Llama Whippin' Intro" }, + url: llamaAudio + } +]; + export const hideAbout = config.hideAbout || false; export const initialState = config.initialState || undefined; export const sentryDsn = SENTRY_DSN; diff --git a/js/constants.js b/js/constants.js index 309fe8e1..802e17d2 100644 --- a/js/constants.js +++ b/js/constants.js @@ -11,6 +11,13 @@ export const LOAD_STYLE = { PLAY: "PLAY" }; +export const MEDIA_TAG_REQUEST_STATUS = { + INITIALIZED: "INITIALIZED", + FAILED: "FAILED", + COMPLETE: "COMPLETE", + NOT_REQUESTED: "NOT_REQUESTED" +}; + export const UTF8_ELLIPSIS = "\u2026"; export const CHARACTER_WIDTH = 5; export const PLAYLIST_RESIZE_SEGMENT_WIDTH = 25; diff --git a/js/fileUtils.js b/js/fileUtils.js index 77f1986c..f54ea1ac 100644 --- a/js/fileUtils.js +++ b/js/fileUtils.js @@ -1,4 +1,56 @@ +import invariant from "invariant"; +import jsmediatags from "jsmediatags/dist/jsmediatags"; + +export function genMediaTags(file) { + invariant( + file != null, + "Attempted to get the tags of media file without passing a file" + ); + // Workaround https://github.com/aadsm/jsmediatags/issues/83 + if (typeof file === "string" && !/^[a-z]+:\/\//i.test(file)) { + file = `${location.protocol}//${location.host}${location.pathname}${file}`; + } + return new Promise((resolve, reject) => { + try { + jsmediatags.read(file, { onSuccess: resolve, onError: reject }); + } catch (e) { + // Possibly jsmediatags could not find a parser for this file? + // Nothing to do. + // Consider removing this after https://github.com/aadsm/jsmediatags/issues/83 is resolved. + reject(e); + } + }); +} + +export function genMediaDuration(url) { + invariant( + typeof url === "string", + "Attempted to get the duration of media file without passing a url" + ); + return new Promise((resolve, reject) => { + // TODO: Does this actually stop downloading the file once it's + // got the duration? + const audio = document.createElement("audio"); + audio.crossOrigin = "anonymous"; + const durationChange = () => { + resolve(audio.duration); + audio.removeEventListener("durationchange", durationChange); + audio.url = null; + // TODO: Not sure if this really gets cleaned up. + }; + audio.addEventListener("durationchange", durationChange); + audio.addEventListener("error", e => { + reject(e); + }); + audio.src = url; + }); +} + export async function genArrayBufferFromFileReference(fileReference) { + invariant( + fileReference != null, + "Attempted to get an ArrayBuffer without assing a fileReference" + ); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = function(e) { @@ -28,7 +80,10 @@ export async function genArrayBufferFromUrl(url) { }); } -export async function promptForFileReferences(accept) { +export async function promptForFileReferences({ + accept = null, + directory = false +}) { return new Promise(resolve => { // Does this represent a memory leak somehow? // Can this fail? Do we ever reject? @@ -36,6 +91,9 @@ export async function promptForFileReferences(accept) { if (accept) fileInput.setAttribute("accept", accept); fileInput.type = "file"; fileInput.multiple = true; + fileInput.webkitdirectory = directory; + fileInput.directory = directory; + fileInput.mozdirectory = directory; // Not entirely sure why this is needed, since the input // was just created, but somehow this helps prevent change // events from getting swallowed. diff --git a/js/index.js b/js/index.js index 247c31e2..aad66b5d 100644 --- a/js/index.js +++ b/js/index.js @@ -7,19 +7,35 @@ import visor from "../skins/Vizor1-01.wsz"; import xmms from "../skins/XMMS-Turquoise.wsz"; import zaxon from "../skins/ZaxonRemake1-0.wsz"; import green from "../skins/Green-Dimension-V2.wsz"; -import llamaAudio from "../mp3/llama-2.91.mp3"; import Winamp from "./winamp"; import { hideAbout, skinUrl, - audioUrl, + initialTracks, initialState, sentryDsn } from "./config"; Raven.config(sentryDsn).install(); +// Requires Dropbox's Chooser to be loaded on the page +function genAudioFileUrlsFromDropbox() { + return new Promise((resolve, reject) => { + if (window.Dropbox == null) { + reject(); + } + window.Dropbox.choose({ + success: resolve, + error: reject, + linkType: "direct", + folderselect: false, + multiselect: true, + extensions: ["video", "audio"] + }); + }); +} + Raven.context(() => { if (hideAbout) { document.getElementsByClassName("about")[0].style.visibility = "hidden"; @@ -30,22 +46,11 @@ Raven.context(() => { return; } - const audio = - audioUrl === undefined - ? { - metaData: { - artist: "DJ Mike Llama", - title: "Llama Whippin' Intro" - }, - url: llamaAudio - } - : { url: audioUrl }; - const winamp = new Winamp({ initialSkin: { url: skinUrl }, - initialTracks: [audio], + initialTracks, avaliableSkins: [ { url: base, name: "" }, { url: green, name: "Green Dimension V2" }, @@ -55,6 +60,18 @@ Raven.context(() => { { url: xmms, name: "XMMS Turquoise " }, { url: zaxon, name: "Zaxon Remake" } ], + filePickers: [ + { + contextMenuName: "Dropbox...", + filePicker: async () => { + const files = await genAudioFileUrlsFromDropbox(); + return files.map(file => ({ + url: file.link, + defaultName: file.name + })); + } + } + ], enableHotkeys: true, __initialState: initialState }); diff --git a/js/loadQueue.js b/js/loadQueue.js new file mode 100644 index 00000000..393d481d --- /dev/null +++ b/js/loadQueue.js @@ -0,0 +1,53 @@ +import invariant from "invariant"; +import TinyQueue from "tinyqueue"; + +// Push promises onto a queue with a priority. +// Run a given number of jobs in parallel +// Useful for prioritizing network requests +export default class LoadQueue { + constructor({ threads }) { + // TODO: Consider not running items with zero priority + // Priority is a function so that items can change their priority between + // when their priority is evaluated. + // For example, we might add a track to the playlist and then scroll to/away + // from it before it gets processed. + this._queue = new TinyQueue([], (a, b) => a.priority() - b.priority()); + this._avaliableThreads = threads; + } + + push(task, priority) { + const t = { task, priority }; + this._queue.push(t); + // Wait until the next event loop to pick a task to run. This way, we can + // enqueue multiple items in an event loop, and be sure they will be run in + // priority order. + setTimeout(() => { + this._run(); + }, 0); + return () => { + // TODO: Could return a boolean representing if the task has already been + // kicke off. + this._queue = this._queue.filter(t1 => t1 !== t); + }; + } + + _run() { + while (this._avaliableThreads > 0) { + if (this._queue.length === 0) { + return; + } + this._avaliableThreads--; + const t = this._queue.pop(); + console.log({ priority: t.priority() }); + const promise = t.task(); + invariant( + typeof promise.then === "function", + `LoadQueue only supports loading Promises. Got ${promise}` + ); + promise.then(() => { + this._avaliableThreads++; + this._run(); + }); + } + } +} diff --git a/js/media/elementSource.js b/js/media/elementSource.js index 61df4398..7c0c6235 100644 --- a/js/media/elementSource.js +++ b/js/media/elementSource.js @@ -69,6 +69,7 @@ export default class ElementSource { } // Async for now, for compatibility with BufferAudioSource + // TODO: This does not need to be async async loadUrl(url) { this._audio.src = url; } diff --git a/js/reducers/playlist.js b/js/reducers/playlist.js index 918f7ca1..2ec16f89 100644 --- a/js/reducers/playlist.js +++ b/js/reducers/playlist.js @@ -16,8 +16,11 @@ import { BUFFER_TRACK, DRAG_SELECTED, SET_MEDIA_TAGS, - SET_MEDIA_DURATION + SET_MEDIA_DURATION, + MEDIA_TAG_REQUEST_INITIALIZED, + MEDIA_TAG_REQUEST_FAILED } from "../actionTypes"; +import { MEDIA_TAG_REQUEST_STATUS } from "../constants"; import { filenameFromUrl } from "../fileUtils"; import { shuffle, moveSelected, mapObject, filterObject } from "../utils"; @@ -145,10 +148,12 @@ const playlist = (state = defaultPlaylistState, action) => { tracks: { ...state.tracks, [action.id]: { + id: action.id, selected: false, defaultName: action.defaultName, duration: null, - url: action.url + url: action.url, + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED } }, // TODO: This could probably be made to work, but we clear it just to be safe. @@ -172,11 +177,34 @@ const playlist = (state = defaultPlaylistState, action) => { ...state.tracks, [action.id]: { ...state.tracks[action.id], + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.COMPLETE, title: action.title, artist: action.artist } } }; + case MEDIA_TAG_REQUEST_INITIALIZED: + return { + ...state, + tracks: { + ...state.tracks, + [action.id]: { + ...state.tracks[action.id], + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.INITIALIZED + } + } + }; + case MEDIA_TAG_REQUEST_FAILED: + return { + ...state, + tracks: { + ...state.tracks, + [action.id]: { + ...state.tracks[action.id], + mediaTagsRequestStatus: MEDIA_TAG_REQUEST_STATUS.FAILED + } + } + }; case SET_MEDIA_DURATION: return { ...state, diff --git a/js/reducers/playlist.test.js b/js/reducers/playlist.test.js index 6a8fbbaf..6ea67d8e 100644 --- a/js/reducers/playlist.test.js +++ b/js/reducers/playlist.test.js @@ -22,9 +22,11 @@ describe("playlist reducer", () => { expect(nextState).toEqual({ tracks: { 100: { + id: 100, selected: false, duration: null, defaultName: "My Track Name", + mediaTagsRequestStatus: "NOT_REQUESTED", url: "url://some-url" } }, @@ -35,8 +37,8 @@ describe("playlist reducer", () => { it("defaults to adding new tracks to the end of the list", () => { const initialState = { tracks: { - 2: { selected: false }, - 3: { selected: false } + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false } }, trackOrder: [3, 2], lastSelectedIndex: 0 @@ -49,11 +51,13 @@ describe("playlist reducer", () => { }); expect(nextState).toEqual({ tracks: { - 2: { selected: false }, - 3: { selected: false }, + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false }, 100: { + id: 100, selected: false, duration: null, + mediaTagsRequestStatus: "NOT_REQUESTED", defaultName: "My Track Name", url: "url://some-url" } @@ -65,8 +69,8 @@ describe("playlist reducer", () => { it("can handle adding a track at a given index", () => { const initialState = { tracks: { - 2: { selected: false }, - 3: { selected: false } + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false } }, trackOrder: [3, 2], lastSelectedIndex: 0 @@ -80,11 +84,13 @@ describe("playlist reducer", () => { }); expect(nextState).toEqual({ tracks: { - 2: { selected: false }, - 3: { selected: false }, + 2: { id: 2, selected: false }, + 3: { id: 3, selected: false }, 100: { + id: 100, selected: false, duration: null, + mediaTagsRequestStatus: "NOT_REQUESTED", defaultName: "My Track Name", url: "url://some-url" } diff --git a/js/selectors.js b/js/selectors.js index c9d036aa..9fbbe2e5 100644 --- a/js/selectors.js +++ b/js/selectors.js @@ -24,7 +24,7 @@ export const getEqfData = state => { return eqfData; }; -const getTracks = state => state.playlist.tracks; +export const getTracks = state => state.playlist.tracks; const getTrackOrder = state => state.playlist.trackOrder; export const getOrderedTracks = createSelector( @@ -44,6 +44,7 @@ export const getSelectedTrackObjects = createSelector( tracks => tracks.filter(track => track.selected) ); +// If a duration is `null`, it counts as zero, which seems fine enough. const runningTimeFromTracks = tracks => tracks.reduce((time, track) => time + Number(track.duration), 0); @@ -156,6 +157,19 @@ export const getVisibleTrackIds = createSelector( trackOrder.slice(offset, offset + numberOfVisibleTracks) ); +export const getTrackIsVisibleFunction = createSelector( + getVisibleTrackIds, + visibleTrackIds => { + return id => visibleTrackIds.includes(id); + } +); + +export const getVisibleTracks = createSelector( + getVisibleTrackIds, + getTracks, + (visibleTrackIds, tracks) => visibleTrackIds.map(id => tracks[id]) +); + export const getPlaylist = state => state.playlist; export const getDuration = state => { diff --git a/js/utils.js b/js/utils.js index 377bf5d7..ca9c1ca6 100644 --- a/js/utils.js +++ b/js/utils.js @@ -37,6 +37,9 @@ export const getTimeObj = time => { }; export const getTimeStr = (time, truncate = true) => { + if (time == null) { + return ""; + } const { minutesFirstDigit, minutesSecondDigit, @@ -229,6 +232,18 @@ export const arrayWith = (arr, value) => { export const arrayWithout = (arr, value) => { const s = new Set(arr); - s["delete"](value); + s.delete(value); return Array.from(s); }; + +export function debounce(func, delay) { + let token; + return function(...args) { + if (token != null) { + clearTimeout(token); + } + token = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +} diff --git a/js/winamp.js b/js/winamp.js index 44a5d53b..fef0a74c 100644 --- a/js/winamp.js +++ b/js/winamp.js @@ -70,7 +70,11 @@ class Winamp { render( - + , node ); diff --git a/package.json b/package.json index 918cfacf..56e2e78f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "cardinal-spline-js": "^2.3.6", "classnames": "^2.2.5", "eslint-plugin-import": "^2.7.0", + "invariant": "^2.2.3", "jest": "^22.0.3", "jsmediatags": "^3.8.1", "jszip": "^3.1.3", @@ -82,6 +83,7 @@ "redux-devtools-extension": "^2.13.2", "redux-thunk": "^2.1.0", "reselect": "^3.0.1", + "tinyqueue": "^1.2.3", "webpack": "^3.6.0", "winamp-eqf": "^1.0.0" }, diff --git a/press.md b/press.md index ab7b6a4d..ab5195dd 100644 --- a/press.md +++ b/press.md @@ -15,7 +15,7 @@ ## News Aggregators -* [/r/InternetIsBeautiful](https://www.reddit.com/r/InternetIsBeautiful/comments/2lh3ob/winamp_2_preserved_in_html5/) +* [/r/InternetIsBeautiful](https://www.reddit.com/r/InternetIsBeautiful/comments/2lh3ob/winamp_2_preserved_in_html5/) * [Hacker News](https://news.ycombinator.com/item?id=16333550) (Feb. 2018) * [Hacker News](https://news.ycombinator.com/item?id=15314629) (Sept. 2017) * [Hacker News](https://news.ycombinator.com/item?id=8565665) (Nov. 2014) @@ -45,6 +45,7 @@ ## Articles (continued) ### Russian + * http://altervision.org/2018/02/winamp-zapustili-v-vashem-brauzere/ * http://droider.ru/post/emulyator-dlya-nostalgii-po-winamp-09-02-2018/ * http://fornote.net/2018/02/winamp2-js-legendarny-j-winamp-dostupny-j-pryamo-v-brauzere/ @@ -62,6 +63,7 @@ * https://tech.onliner.by/2018/02/09/winamp-5 ### Spanish + * http://datainfox.com/2018/02/lanzan-version-web-winamp/ * http://html5facil.com/noticias/winamp-web/ * http://omicrono.elespanol.com/2014/11/winamp-2-en-html5-es-nostalgia-pura/ @@ -81,6 +83,7 @@ * https://www.rocambola.com/winamp2-js-la-nueva-version-del-famoso-reproductor-en-formato-web/ ### Chinese + * http://baijiahao.baidu.com/s?id=1592074295875163282 * http://blog.csdn.net/souhugirl/article/details/43907469 * http://m.sohu.com/a/221750379_549015 @@ -95,6 +98,7 @@ * https://www.waerfa.com/winamp2-js ### English + * http://entertainment.ie/trending/news/Remember-Winamp-Now-theres-a-web-browser-version-of-it/402141.htm * http://newsvire.com/get-some-media-player-nostalgia-with-this-web-version-of-winamp/ * http://osxdaily.com/2018/02/10/run-winamp-web-browser/ @@ -103,8 +107,12 @@ * https://thenextweb.com/shareables/2018/02/09/bring-nostalgia-browser-winamp-emulator/ * https://www.follownews.com/winamp2js-is-a-webbased-version-of-audio-player-winamp-43icg * https://www.gizmodo.com.au/2018/02/someone-remade-winamp-so-it-runs-in-your-browser/ +* https://changelog.com/news/winamp-29-in-htmljs--PJr +* https://www.howtogeek.com/343813/re-live-90s-computing-in-your-browser-right-now/amp/ +* https://techcaption.com/run-winamp-web-browser/ ### German + * http://de.engadget.com/2018/02/09/retro-winamp-emulator-fur-deinen-browser/ * http://www.chip.de/news/Winamp-ist-zurueck-Kult-Player-ueber-Browser-nutzen_124413881.html * http://www.intro.de/life/mit-winamp-zuruck-in-die-vergangenheit-nostalgische-browser-version-des-media-players @@ -113,6 +121,7 @@ * https://www.smartdroid.de/winamp2-js-laesst-den-klassiker-im-browser-wieder-aufleben/ ### Polish + * https://www.antyradio.pl/Technologia/Internet/Winamp-powrocil-jako-strona-internetowa-17497 * http://innpoland.pl/137663,kultowy-winamp-powraca-w-nowej-formie-uderza-tam-gdzie-jest-juz-spotify * http://www.instalki.pl/aktualnosci/software/30055-winamp-przegladarka.html @@ -120,6 +129,7 @@ * https://www.dobreprogramy.pl/Kultowy-odtwarzacz-muzyki-Winamp-powrocil-w-przegladarce-internetowej,News,86017.html ### French + * http://www.phonandroid.com/winamps-lecteur-multimedia-retour.html * http://www.lemonde.fr/pixels/breve/2014/11/07/winamp-en-html_4520190_4408996.html * https://korben.info/winamp-en-html-javascript.html @@ -128,6 +138,7 @@ * https://www.macg.co/logiciels/2018/02/revivez-les-glorieuses-annees-winamp-avec-votre-navigateur-101345 ### Italian + * https://tech.everyeye.it/notizie/il-ritorno-winamp-storico-player-riappare-in-una-versione-web-320373.html * https://www.hwupgrade.it/news/web/winamp-arriva-su-qualsiasi-browser-web-nostalgici-gioiscono_74100.html * https://www.wired.it/internet/web/2018/02/09/winamp-rinasce-sul-web/ @@ -135,47 +146,63 @@ * https://www.windowsblogitalia.com/2018/02/ascoltare-la-musica-edge-firefox-chrome-winamp2-js/ ### Thai + * http://www.flashfly.net/wp/208175 * https://www.aripfan.com/bring-winamp-emulator-in-web-browser/ * https://www.beartai.com/news/itnews/221530 ### Hungarian -* http://hvg.hu/tudomany/20180212_winamp_zenelejatszo_letoltes_bongeszoben + +* http://hvg.hu/tudomany/20180212_winamp_zenelejatszo_letoltes_bongeszoben * http://www.karpatinfo.net/cikk/tudomany/2567147-feltamadt-winamp-90-es-evek-kedvenc-zenelejatszoja-hasznalhatja * https://www.gamestar.hu/hir/winamp-html5-verzio-243839.html ### Bosnian + * https://www.b92.net/tehnopolis/vesti.php?yyyy=2018&mm=02&nav_id=1357852 * https://www.benchmark.rs/vesti/novi_winamp_emulator_budi_nostalgiju_na_stara_vremena-74941 ### Turkish + * http://www.bursateknikpc.com/winamp-2nin-html5-de-yazilmis-versiyonu-cikti/ * https://www.log.com.tr/winamp-efsanesini-yeniden-kullanima-sunan-site/ ### Japanese + * http://jp.techcrunch.com/2018/02/10/2018-02-09-whip-the-llamas-ass-with-this-javascript-winamp-emulator/ * https://gigazine.net/news/20180213-winamp2-js/ * https://www.gizmodo.jp/2014/11/90winamp_2html5.html ### Portuguese + * http://abertoatedemadrugada.com/2018/02/winamp-completamente-funcional-em.html * https://canaltech.com.br/musica/desenvolvedor-ressuscita-winamp-em-emulador-com-javascript-108112/ ### Swedish + * http://gaffa.se/nyhet/125537/klassisk-musikspelare-nu-pa-webben/ * https://www.idg.se/2.1085/1.697529/mp3-winamp-webblasare ### Armenian + * http://www.armblog.net/2018/02/github-winamp.htm ### Dutch + * https://tweakers.net/geek/135087/ontwikkelaar-bouwt-winamp-29-in-javascript.html ### Romanian + * https://zonait.tv/va-era-dor-de-winamp/ ### Czech + * https://www.zive.cz/clanky/zpatky-do-minulosti-winamp-muzete-pouzivat-primo-v-prohlizeci/sc-3-a-191743/default.aspx ### Croatian + * http://idesh.net/tech-i-web/internet/winamp-se-vratio-nostalgija-na-najjace/ + +### Vietnamese + +* http://vnreview.vn/tin-tuc-xa-hoi-so/-/view_content/content/2421915/song-lai-thap-nien-90-voi-trai-nghiem-nhung-phan-mem-nay-ngay-tren-trinh-duyet diff --git a/yarn.lock b/yarn.lock index c4db5113..81b43b6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3152,6 +3152,12 @@ invariant@^2.0.0, invariant@^2.2.2: dependencies: loose-envify "^1.0.0" +invariant@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.3.tgz#1a827dfde7dcbd7c323f0ca826be8fa7c5e9d688" + dependencies: + loose-envify "^1.0.0" + invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" @@ -6208,6 +6214,10 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +tinyqueue@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-1.2.3.tgz#b6a61de23060584da29f82362e45df1ec7353f3d" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"