Add dropbox support

This commit is contained in:
Jordan Eldredge 2018-03-09 20:32:55 -08:00
parent 700b7392ca
commit 33e33b2dec
13 changed files with 250 additions and 86 deletions

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,11 @@
import jsmediatags from "jsmediatags/dist/jsmediatags";
import { parser, creator } from "winamp-eqf";
import {
genArrayBufferFromFileReference,
genArrayBufferFromUrl,
promptForFileReferences
promptForFileReferences,
genMediaDuration,
genMediaTags,
genAudioFileUrlsFromDropbox
} from "./fileUtils";
import skinParser from "./skinParser";
import {
@ -27,7 +29,8 @@ import {
base64FromArrayBuffer,
downloadURI,
normalize,
sort
sort,
debounce
} from "./utils";
import {
CLOSE_WINAMP,
@ -61,9 +64,17 @@ import {
TOGGLE_SHADE_MODE,
TOGGLE_PLAYLIST_SHADE_MODE,
MEDIA_TAG_REQUEST_INITIALIZED,
MEDIA_TAG_REQUEST_FAILED
MEDIA_TAG_REQUEST_FAILED,
PLAYLIST_SIZE_CHANGED
} from "./actionTypes";
import LoadQueue from "./loadQueue";
const DURATION_PRIORITY = 5;
const META_DATA_PRIORITY = 10;
const loadQueue = new LoadQueue({ threads: 4 });
function playRandomTrack() {
return (dispatch, getState) => {
const { playlist: { trackOrder, currentTrack } } = getState();
@ -237,22 +248,14 @@ 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");
audio.crossOrigin = "anonymous";
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;
loadQueue.push(() => {
return 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?
});
// TODO: The priority should depend upon visiblity
}, () => DURATION_PRIORITY);
};
}
@ -313,52 +316,51 @@ 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(fetchMediaTagsForVisibleTracks());
}
};
}
const _fetchMediaTagsForVisibleTracksThunk = debounce((dispatch, getState) => {
getVisibleTracks(getState())
.filter(
track =>
track.mediaTagsRequestStatus === MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED
)
.forEach(track => {
loadQueue.push(
() => dispatch(fetchMediaTags(track.url, track.id)),
() => META_DATA_PRIORITY
);
});
}, 200);
export function fetchMediaTagsForVisibleTracks() {
return (dispatch, getState) => {
getVisibleTracks(getState())
.filter(
track =>
track.mediaTagsRequestStatus ===
MEDIA_TAG_REQUEST_STATUS.NOT_REQUESTED
)
.forEach(track => {
dispatch(fetchMediaTags(track.url, track.id));
});
};
return _fetchMediaTagsForVisibleTracksThunk;
}
export const debouncedFetchMediaTagsForVisibleTracks = debounce(
fetchMediaTagsForVisibleTracks,
200
);
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 => {
dispatch({ type: MEDIA_TAG_REQUEST_INITIALIZED, id });
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: () => {
dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id });
// Nothing to do. The filename will have to suffice.
}
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.
dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id });
}
};
}
@ -415,23 +417,6 @@ export function openSkinFileDialog() {
return _openFileDialog(".zip, .wsz");
}
// 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"]
});
});
}
export function openDropboxFileDialog() {
return async dispatch => {
const files = await genAudioFileUrlsFromDropbox();
@ -566,7 +551,17 @@ export function toggleVisualizerStyle() {
}
export function setPlaylistScrollPosition(position) {
return { type: SET_PLAYLIST_SCROLL_POSITION, position };
return dispatch => {
dispatch({ type: SET_PLAYLIST_SCROLL_POSITION, position });
dispatch(fetchMediaTagsForVisibleTracks());
};
}
export function setPlaylistSize(size) {
return dispatch => {
dispatch({ type: PLAYLIST_SIZE_CHANGED, size });
dispatch(fetchMediaTagsForVisibleTracks());
};
}
export function scrollNTracks(n) {

View file

@ -5,7 +5,8 @@ import {
close,
setSkinFromUrl,
openMediaFileDialog,
openSkinFileDialog
openSkinFileDialog,
openDropboxFileDialog
} from "../../actionCreators";
import { ContextMenu, Hr, Node, Parent, LinkNode } from "../ContextMenu";
@ -21,7 +22,10 @@ const MainContextMenu = props => (
label="Winamp2-js"
/>
<Hr />
<Node onClick={props.openMediaFileDialog} label="Play File..." />
<Parent label="Play">
<Node onClick={props.openMediaFileDialog} label="File..." />
<Node onClick={props.openDropboxFileDialog} label="Dropbox..." />
</Parent>
<Parent label="Skins">
<Node onClick={props.openSkinFileDialog} label="Load Skin..." />
{!!props.avaliableSkins.length && <Hr />}
@ -46,6 +50,7 @@ const mapDispatchToProps = {
close,
openSkinFileDialog,
openMediaFileDialog,
openDropboxFileDialog,
setSkin: setSkinFromUrl
};

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

@ -48,6 +48,7 @@ class PlaylistWindow extends React.Component {
_handleDrop(e, targetCoords) {
const top = e.clientY - targetCoords.y;
// TODO: Include the scroll offset in this
const atIndex = clamp(
Math.round((top - 23) / TRACK_HEIGHT),
0,

View file

@ -1,4 +1,73 @@
import invariant from "invariant";
import jsmediatags from "jsmediatags/dist/jsmediatags";
// Requires Dropbox's Chooser to be loaded on the page
export 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"]
});
});
}
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) {

47
js/loadQueue.js Normal file
View file

@ -0,0 +1,47 @@
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 prioirty
// 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);
this._run();
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();
const promise = t.task();
invariant(
typeof promise.then === "function",
`LoadQueue only supports loading Promises. Got ${promise}`
);
promise.then(() => {
this._avaliableThreads++;
this._run();
});
}
}
}

View file

@ -148,6 +148,7 @@ const playlist = (state = defaultPlaylistState, action) => {
tracks: {
...state.tracks,
[action.id]: {
id: action.id,
selected: false,
defaultName: action.defaultName,
duration: null,

View file

@ -22,6 +22,7 @@ describe("playlist reducer", () => {
expect(nextState).toEqual({
tracks: {
100: {
id: 100,
selected: false,
duration: null,
defaultName: "My Track Name",
@ -36,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
@ -50,9 +51,10 @@ 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",
@ -67,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
@ -82,9 +84,10 @@ 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",

View file

@ -232,3 +232,15 @@ export const arrayWithout = (arr, 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);
};
}

20
loadQueue.test.js Normal file
View file

@ -0,0 +1,20 @@
import LoadQueue from "./loadQueue";
describe("LoadQueue", () => {
it("executes some promises", () => {
const results = [];
const makePushPromise = n => {
return new Promise(resolve => {
results.push(n);
resolve();
});
};
const task1 = makePushPromise(1);
const task2 = makePushPromise(2);
const task3 = makePushPromise(3);
const task4 = makePushPromise(4);
const loadQueue = new LoadQueue({ threads: 4 });
});
});

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

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