Improve how files get loaded

This commit is contained in:
Jordan Eldredge 2018-02-19 10:42:46 -08:00
parent 62a6dd17a2
commit 2bc90d9a90
12 changed files with 161 additions and 65 deletions

View file

@ -58,7 +58,10 @@ import Winamp from 'winamp2-js';
const winamp = new Winamp({
initialTrack: {
name: "1. DJ Mike Llama - Llama Whippin' Intro",
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro",
},
url: "https://d38dnrh1liu4f5.cloudfront.net/projects/winamp2-js/mp3/llama-2.91.mp3"
},
initialSkin: {

View file

@ -3,10 +3,11 @@ import { parser, creator } from "winamp-eqf";
import {
genArrayBufferFromFileReference,
genArrayBufferFromUrl,
promptForFileReferences
promptForFileReferences,
filenameFromUrl
} from "./fileUtils";
import skinParser from "./skinParser";
import { BANDS, TRACK_HEIGHT } from "./constants";
import { BANDS, TRACK_HEIGHT, LOAD_STYLE } from "./constants";
import {
getEqfData,
nextTrack,
@ -77,7 +78,7 @@ export function play() {
state.playlist.curentTrack == null &&
state.playlist.trackOrder.length === 0
) {
dispatch(openFileDialog());
dispatch(openMediaFileDialog());
} else {
dispatch({ type: PLAY });
}
@ -196,20 +197,11 @@ function setEqFromFileReference(fileReference) {
}
export function addTracksFromReferences(fileReferences, autoPlay, atIndex) {
return dispatch => {
if (autoPlay) {
// I'm the worst. It just so happens that in every case that we autoPlay,
// we should also clear all tracks.
dispatch(removeAllTracks());
}
Array.from(fileReferences).forEach((file, i) => {
const priority = i === 0 && autoPlay ? "PLAY" : "NONE";
const id = uniqueId();
const url = URL.createObjectURL(file);
dispatch(_addTrackFromUrl(url, file.name, id, priority, atIndex + i));
dispatch(fetchMediaTags(file, id));
});
};
const tracks = Array.from(fileReferences).map(file => ({
blob: file,
defaultName: file.name
}));
return loadMediaFiles(tracks, autoPlay, atIndex);
}
const SKIN_FILENAME_MATCHER = new RegExp("(wsz|zip)$", "i");
@ -245,8 +237,18 @@ export function fetchMediaDuration(url, id) {
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?
/* Disabled, because people might drag in bogus local files.
Raven.captureMessage(
`Error getting duration of ${url}: ${audio.error.message}`
);
*/
});
audio.src = url;
};
}
@ -256,29 +258,74 @@ function uniqueId() {
return counter++;
}
function _addTrackFromUrl(url, name, id, priority, atIndex) {
/*
type Track = {
defaultName: ?string,
duration: ?number,
url?: string,
blob?: fileReference
}
*/
export function loadMediaFiles(tracks, autoPlay = true, atIndex = 0) {
return dispatch => {
dispatch({ type: ADD_TRACK_FROM_URL, url, name, id, atIndex });
switch (priority) {
case "BUFFER":
dispatch({ type: BUFFER_TRACK, name, id });
break;
case "PLAY":
dispatch({ type: PLAY_TRACK, name, id });
break;
default:
// If we're not going to load this right away,
// we should fetch duration on our own
dispatch(fetchMediaDuration(url, id));
if (autoPlay) {
// I'm the worst. It just so happens that in every case that we autoPlay,
// we should also clear all tracks.
dispatch(removeAllTracks());
}
tracks.forEach((track, i) => {
const priority = i === 0 && autoPlay ? LOAD_STYLE.PLAY : null;
dispatch(loadMediaFile(track, priority, atIndex));
});
};
}
export function loadMediaFromUrl(url, name, priority) {
export function loadMediaFile(track, priority = null, atIndex = 0) {
return dispatch => {
const id = uniqueId();
dispatch(_addTrackFromUrl(url, name, id, priority));
dispatch(fetchMediaTags(url, id));
const { url, blob, defaultName, metaData, duration } = track;
let canonicalUrl = url;
let canonicalDefaultName = defaultName;
if (canonicalUrl == null) {
if (blob == null) {
throw new Error("Expected track to have either a blob or a url");
}
canonicalUrl = URL.createObjectURL(track.blob);
} else if (!defaultName) {
canonicalDefaultName = filenameFromUrl(url);
// TODO: Derive the defaultName from the URL if none is provided
}
dispatch({
type: ADD_TRACK_FROM_URL,
url: canonicalUrl,
name: canonicalDefaultName,
id,
atIndex
});
switch (priority) {
case LOAD_STYLE.BUFFER:
dispatch({ type: BUFFER_TRACK, id });
break;
case LOAD_STYLE.PLAY:
dispatch({ type: PLAY_TRACK, id });
break;
default:
// If we're not going to load this right away,
// we should set duration on our own
if (duration != null) {
dispatch({ type: SET_MEDIA_DURATION, duration, id });
} else {
dispatch(fetchMediaDuration(canonicalUrl, id));
}
}
if (metaData != null) {
const { artist, title } = metaData;
dispatch({ type: SET_MEDIA_TAGS, artist, title, id });
} else {
dispatch(fetchMediaTags(url || blob, id));
}
};
}
@ -339,13 +386,36 @@ export function setSkinFromUrl(url) {
};
}
export function openFileDialog(accept) {
// This function is private, since Winamp consumers may wish to support
// opening files via other means (Dropbox?). 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);
dispatch(loadFilesFromReferences(fileReferences));
};
}
export function openEqfFileDialog() {
return _openFileDialog(".eqf");
}
export function openMediaFileDialog() {
return async (dispatch, getState, { promptForMediaFiles }) => {
if (promptForMediaFiles == null) {
return dispatch(_openFileDialog());
}
const mediaFiles = await promptForMediaFiles();
if (mediaFiles != null) {
return dispatch(loadMediaFiles(mediaFiles, true, null));
}
};
}
export function openSkinFileDialog() {
return _openFileDialog(".zip, .wsz");
}
export function setEqBand(band, value) {
return { type: SET_BAND_VALUE, band, value };
}

View file

@ -1,18 +1,15 @@
import React from "react";
import { connect } from "react-redux";
import { openFileDialog, downloadPreset } from "../../actionCreators";
import { openEqfFileDialog, downloadPreset } from "../../actionCreators";
import { ContextMenu, Node } from "../ContextMenu";
const MainContextMenu = props => (
<ContextMenu top id="presets-context" handle={<div id="presets" />}>
<Node onClick={props.openFileDialog} label="Load" />
<Node onClick={props.openEqfFileDialog} label="Load" />
<Node onClick={props.downloadPreset} label="Save" />
</ContextMenu>
);
const mapDispatchToProps = {
openFileDialog: () => openFileDialog(".eqf"),
downloadPreset
};
const mapDispatchToProps = { openEqfFileDialog, downloadPreset };
export default connect(null, mapDispatchToProps)(MainContextMenu);

View file

@ -1,12 +1,12 @@
import React from "react";
import { connect } from "react-redux";
import { openFileDialog } from "../../actionCreators";
import { openMediaFileDialog } from "../../actionCreators";
const Eject = props => (
<div id="eject" onClick={props.openFileDialog} title="Open File(s)" />
<div id="eject" onClick={props.openMediaFileDialog} title="Open File(s)" />
);
const mapDispatchToProps = { openFileDialog };
const mapDispatchToProps = { openMediaFileDialog };
export default connect(null, mapDispatchToProps)(Eject);

View file

@ -1,7 +1,12 @@
import React from "react";
import { connect } from "react-redux";
import ClickedDiv from "../ClickedDiv";
import { close, setSkinFromUrl, openFileDialog } from "../../actionCreators";
import {
close,
setSkinFromUrl,
openMediaFileDialog,
openSkinFileDialog
} from "../../actionCreators";
import { ContextMenu, Hr, Node, Parent, LinkNode } from "../ContextMenu";
const MainContextMenu = props => (
@ -16,9 +21,9 @@ const MainContextMenu = props => (
label="Winamp2-js"
/>
<Hr />
<Node onClick={props.openFileDialogForMedia} label="Play File..." />
<Node onClick={props.openMediaFileDialog} label="Play File..." />
<Parent label="Skins">
<Node onClick={props.openFileDialogForSkin} label="Load Skin..." />
<Node onClick={props.openSkinFileDialog} label="Load Skin..." />
{!!props.avaliableSkins.length && <Hr />}
{props.avaliableSkins.map(skin => (
<Node
@ -39,8 +44,8 @@ const mapStateToProps = state => ({
const mapDispatchToProps = {
close,
openFileDialogForSkin: () => openFileDialog(".zip, .wsz"),
openFileDialogForMedia: openFileDialog,
openSkinFileDialog,
openMediaFileDialog,
setSkin: setSkinFromUrl
};

View file

@ -7,7 +7,7 @@ import {
stop,
next,
previous,
openFileDialog
openMediaFileDialog
} from "../../actionCreators";
import MiniTime from "../MiniTime";
@ -22,7 +22,10 @@ const PlaylistWindow = props => (
<div className="playlist-pause-button" onClick={props.pause} />
<div className="playlist-stop-button" onClick={props.stop} />
<div className="playlist-next-button" onClick={props.next} />
<div className="playlist-eject-button" onClick={props.openFileDialog} />
<div
className="playlist-eject-button"
onClick={props.openMediaFileDialog}
/>
</div>
<MiniTime />
</React.Fragment>
@ -32,7 +35,7 @@ const mapDispatchToProps = {
play,
pause,
stop,
openFileDialog,
openMediaFileDialog,
next,
previous
};

View file

@ -2,7 +2,7 @@ import React from "react";
import { Provider } from "react-redux";
import renderer from "react-test-renderer";
import getStore from "../../store";
import { loadMediaFromUrl } from "../../actionCreators";
import { loadMediaFiles } from "../../actionCreators";
import PlaylistShade from "./PlaylistShade";
@ -21,7 +21,9 @@ describe("PlaylistShade", () => {
});
it("renders to snapshot", () => {
store.dispatch(loadMediaFromUrl("http://example.com", "Some Name", "NONE"));
store.dispatch(
loadMediaFiles([{ url: "http://example.com", defaultName: "Some Name" }])
);
const tree = renderer
.create(
<Provider store={store}>

View file

@ -6,6 +6,11 @@ export const WINDOWS = {
EQUALIZER: "EQUALIZER"
};
export const LOAD_STYLE = {
BUFFER: "BUFFER",
PLAY: "PLAY"
};
export const UTF8_ELLIPSIS = "\u2026";
export const CHARACTER_WIDTH = 5;
export const PLAYLIST_RESIZE_SEGMENT_WIDTH = 25;

View file

@ -47,3 +47,12 @@ export async function promptForFileReferences(accept) {
fileInput.click();
});
}
// This is not perfect, but... meh: https://stackoverflow.com/a/36756650/1263117
export function filenameFromUrl(url) {
return url
.split("/")
.pop()
.split("#")[0]
.split("?")[0];
}

View file

@ -5,7 +5,7 @@ import {
adjustVolume,
toggleRepeat,
toggleShuffle,
openFileDialog,
openMediaFileDialog,
seekForward,
seekBackward,
reverseList,
@ -72,7 +72,7 @@ export default function(dispatch) {
dispatch(pause());
break;
case 76: // L
dispatch(openFileDialog());
dispatch(openMediaFileDialog());
break;
case 82: // R
dispatch(toggleRepeat());
@ -90,7 +90,7 @@ export default function(dispatch) {
dispatch(previous());
break;
case 96: // numpad 0
dispatch(openFileDialog());
dispatch(openMediaFileDialog());
break;
case 97: // numpad 1
dispatch(nextN(-10));

View file

@ -34,7 +34,11 @@ Raven.context(() => {
url: skinUrl
},
initialTrack: {
name: "DJ Mike Llama - Llama Whippin' Intro",
defaultName: "DJ Mike Llama - Llama Whippin' Intro",
metaData: {
artist: "DJ Mike Llama",
title: "Llama Whippin' Intro"
},
url: audioUrl
},
avaliableSkins: [

View file

@ -6,7 +6,8 @@ import getStore from "./store";
import App from "./components/App";
import Hotkeys from "./hotkeys";
import Media from "./media";
import { setSkinFromUrl, loadMediaFromUrl } from "./actionCreators";
import { setSkinFromUrl, loadMediaFile } from "./actionCreators";
import { LOAD_STYLE } from "./constants";
import { SET_AVALIABLE_SKINS } from "./actionTypes";
@ -39,17 +40,14 @@ class Winamp {
this.options = options;
this.media = new Media();
this.store = getStore(this.media, this.options.__initialState);
this.store = getStore(this.media, this.options);
this.store.dispatch(setSkinFromUrl(this.options.initialSkin.url));
// TODO: Make this initial track_s_
if (this.options.initialTrack && this.options.initialTrack.url) {
this.store.dispatch(
loadMediaFromUrl(
this.options.initialTrack.url,
this.options.initialTrack.name,
"BUFFER"
)
loadMediaFile(this.options.initialTrack, LOAD_STYLE.BUFFER)
);
}
if (this.options.avaliableSkins) {