Merges upstream/master and resolves conflicts

This commit is contained in:
Jakub Ďuraš 2018-03-12 11:50:34 +01:00
commit 1c97cce4e1
28 changed files with 415 additions and 100 deletions

View file

@ -2,3 +2,4 @@
built/
coverage/
**/node_modules/
experiments/winamp-skin-museum/

View file

@ -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],

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ node_modules
/built
/coverage
/experiments/winamp-skin-museum

View file

@ -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);

View file

@ -29,6 +29,7 @@
<a href='mailto:jordan@jordaneldredge.com?subject=Winamp2-js%20Feedback'>Feedback</a> |
<a href='https://github.com/captbaritone/winamp2-js'>GitHub</a>
</p>
<script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="7py29249dpeddu8"></script>
<script src="built/winamp.js"></script>
</body>

View file

@ -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();

View file

@ -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";

View file

@ -22,13 +22,14 @@ const App = ({
equalizer,
playlist,
openWindows,
container
container,
filePickers
}) => {
if (closed) {
return null;
}
const windows = {
main: <MainWindow mediaPlayer={media} />,
main: <MainWindow mediaPlayer={media} filePickers={filePickers} />,
equalizer: equalizer && <EqualizerWindow />,
playlist: playlist && <PlaylistWindow />
};

View file

@ -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"
/>
<Hr />
<Node onClick={props.openMediaFileDialog} label="Play File..." />
<Parent label="Play">
<Node onClick={props.openMediaFileDialog} label="File..." />
{props.filePickers &&
props.filePickers.map((picker, i) => (
<Node
key={i}
onClick={async () => {
props.loadMediaFiles(await picker.filePicker(), LOAD_STYLE.PLAY);
}}
label={picker.contextMenuName}
/>
))}
</Parent>
<Parent label="Skins">
<Node onClick={props.openSkinFileDialog} label="Load Skin..." />
{!!props.avaliableSkins.length && <Hr />}
@ -46,6 +60,7 @@ const mapDispatchToProps = {
close,
openSkinFileDialog,
openMediaFileDialog,
loadMediaFiles,
setSkin: setSkinFromUrl
};

View file

@ -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}
>
<MainContextMenu />
<MainContextMenu filePickers={filePickers} />
{shade && <MiniTime />}
<Minimize />
<Shade />

View file

@ -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 }) => (
<PlaylistMenu id="playlist-add-menu">
<div
className="add-url"
onClick={() => alert("Not supported in Winamp2-js")}
/>
<div
className="add-dir"
onClick={() => alert("Not supported in Winamp2-js")}
/>
<div className="add-dir" onClick={() => addDirAtIndex(nextIndex)} />
<div className="add-file" onClick={() => addFilesAtIndex(nextIndex)} />
</PlaylistMenu>
);
@ -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));
}
});

View file

@ -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);

View file

@ -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,

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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.

View file

@ -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: "<Base Skin>" },
{ 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
});

53
js/loadQueue.js Normal file
View file

@ -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();
});
}
}
}

View file

@ -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;
}

View file

@ -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,

View file

@ -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"
}

View file

@ -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 => {

View file

@ -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);
};
}

View file

@ -70,7 +70,11 @@ class Winamp {
render(
<Provider store={this.store}>
<App media={this.media} container={this.options.container} />
<App
media={this.media}
container={this.options.container}
filePickers={this.options.filePickers}
/>
</Provider>,
node
);

View file

@ -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"
},

View file

@ -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

View file

@ -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"