webamp/js/actionCreators/files.ts
Jordan Eldredge 76af4b6436 Revert "POC: Try adding rexux-observable"
This reverts commit 58fd75c8a1.
2019-06-14 22:38:10 -07:00

367 lines
10 KiB
TypeScript

import { parser, creator } from "winamp-eqf";
import { BANDS, LOAD_STYLE } from "../constants";
import * as Utils from "../utils";
import {
promptForFileReferences,
genArrayBufferFromFileReference,
genMediaDuration,
genMediaTags,
} from "../fileUtils";
import skinParser from "../skinParser";
import {
getTracks,
getTrackIsVisibleFunction,
getEqfData,
getPlaylistURL,
} from "../selectors";
import {
ADD_TRACK_FROM_URL,
PLAY_TRACK,
BUFFER_TRACK,
SET_MEDIA_TAGS,
SET_MEDIA_DURATION,
MEDIA_TAG_REQUEST_INITIALIZED,
MEDIA_TAG_REQUEST_FAILED,
SET_SKIN_DATA,
LOADED,
LOADING,
} from "../actionTypes";
import LoadQueue from "../loadQueue";
import { removeAllTracks } from "./playlist";
import { setPreamp, setEqBand } from "./equalizer";
import { LoadStyle, Thunk, Track, EqfPreset, SkinData } from "../types";
// 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 });
export function addTracksFromReferences(
fileReferences: FileList,
loadStyle: LoadStyle,
atIndex: number | undefined
): Thunk {
const tracks: Track[] = Array.from(fileReferences).map(file => ({
blob: file,
defaultName: file.name,
}));
return loadMediaFiles(tracks, loadStyle, atIndex);
}
const SKIN_FILENAME_MATCHER = new RegExp("(wsz|zip)$", "i");
const EQF_FILENAME_MATCHER = new RegExp("eqf$", "i");
export function loadFilesFromReferences(
fileReferences: FileList,
loadStyle: LoadStyle = LOAD_STYLE.PLAY,
atIndex: number | undefined = undefined
): Thunk {
return dispatch => {
if (fileReferences.length < 1) {
return;
} else if (fileReferences.length === 1) {
const fileReference = fileReferences[0];
if (SKIN_FILENAME_MATCHER.test(fileReference.name)) {
dispatch(setSkinFromBlob(fileReference));
return;
} else if (EQF_FILENAME_MATCHER.test(fileReference.name)) {
dispatch(setEqFromFileReference(fileReference));
return;
}
}
dispatch(addTracksFromReferences(fileReferences, loadStyle, atIndex));
};
}
export function setSkinFromBlob(blob: Blob | Promise<Blob>): Thunk {
return async (dispatch, getState, { requireJSZip }) => {
if (!requireJSZip) {
alert("Webamp has not been configured to support custom skins.");
return;
}
dispatch({ type: LOADING });
let JSZip;
try {
JSZip = await requireJSZip();
} catch (e) {
console.error(e);
dispatch({ type: LOADED });
alert("Failed to load the skin parser.");
return;
}
try {
const skinData = await skinParser(blob, JSZip);
dispatch({
type: SET_SKIN_DATA,
data: {
skinImages: skinData.images,
skinColors: skinData.colors,
skinPlaylistStyle: skinData.playlistStyle,
skinCursors: skinData.cursors,
skinRegion: skinData.region,
skinGenLetterWidths: skinData.genLetterWidths,
skinGenExColors: skinData.genExColors,
} as SkinData,
});
} catch (e) {
console.error(e);
dispatch({ type: LOADED });
alert(`Failed to parse skin`);
}
};
}
export function setSkinFromUrl(url: string): Thunk {
return async dispatch => {
dispatch({ type: LOADING });
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
dispatch(setSkinFromBlob(response.blob()));
} catch (e) {
console.error(e);
dispatch({ type: LOADED });
alert(`Failed to download skin from ${url}`);
}
};
}
// 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: string | null): Thunk {
return async dispatch => {
const fileReferences = await promptForFileReferences({ accept });
dispatch(loadFilesFromReferences(fileReferences));
};
}
export function openEqfFileDialog(): Thunk {
return _openFileDialog(".eqf");
}
export function openMediaFileDialog(): Thunk {
return _openFileDialog(null);
}
export function openSkinFileDialog() {
return _openFileDialog(".zip, .wsz");
}
export function fetchMediaDuration(url: string, id: number): Thunk {
return (dispatch, getState) => {
loadQueue.push(
async () => {
try {
const duration = await genMediaDuration(url);
dispatch({ type: SET_MEDIA_DURATION, duration, id });
} catch (e) {
// 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;
}
);
};
}
export function loadMedia(
e: React.DragEvent<HTMLDivElement>,
loadStyle: LoadStyle = LOAD_STYLE.NONE,
atIndex = 0
): Thunk {
const { files } = e.dataTransfer;
return async (dispatch, getState, { handleTrackDropEvent }) => {
if (handleTrackDropEvent) {
const tracks = await handleTrackDropEvent(e);
if (tracks != null) {
dispatch(loadMediaFiles(tracks, loadStyle, atIndex));
return;
}
}
dispatch(loadFilesFromReferences(files, loadStyle, atIndex));
};
}
export function loadMediaFiles(
tracks: Track[],
loadStyle: LoadStyle = LOAD_STYLE.NONE,
atIndex = 0
): Thunk {
return dispatch => {
if (loadStyle === LOAD_STYLE.PLAY) {
// 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 ? loadStyle : LOAD_STYLE.NONE;
dispatch(loadMediaFile(track, priority, atIndex + i));
});
};
}
export function loadMediaFile(
track: Track,
priority: LoadStyle = LOAD_STYLE.NONE,
atIndex = 0
): Thunk {
return dispatch => {
const id = Utils.uniqueId();
const { defaultName, metaData, duration } = track;
let canonicalUrl: string;
if ("url" in track) {
canonicalUrl = track.url.toString();
} else if ("blob" in track) {
canonicalUrl = URL.createObjectURL(track.blob);
} else {
throw new Error("Expected track to have either a blob or a url");
}
dispatch({
type: ADD_TRACK_FROM_URL,
url: canonicalUrl,
duration: track.duration,
defaultName,
id,
atIndex,
});
switch (priority) {
case LOAD_STYLE.BUFFER:
dispatch({ type: BUFFER_TRACK, id });
break;
case LOAD_STYLE.PLAY:
dispatch({ type: PLAY_TRACK, id });
break;
case LOAD_STYLE.NONE:
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, album } = metaData;
dispatch({
type: SET_MEDIA_TAGS,
artist,
title,
album,
// For now, we lie about these next three things.
// TODO: Ideally we would leave these as null and force a media data
// fetch when the user starts playing.
sampleRate: 44000,
bitrate: 192000,
numberOfChannels: 2,
id,
});
} else if ("blob" in track) {
// Blobs can be loaded quickly
dispatch(fetchMediaTags(track.blob, id));
} else {
dispatch(queueFetchingMediaTags(id));
}
};
}
function queueFetchingMediaTags(id: number): Thunk {
return (dispatch, getState) => {
const track = getTracks(getState())[id];
loadQueue.push(
() => dispatch(fetchMediaTags(track.url, id)),
() => {
const trackIsVisible = getTrackIsVisibleFunction(getState());
return trackIsVisible(id)
? META_DATA_VISIBLE_PRIORITY
: META_DATA_PRIORITY;
}
);
};
}
export function fetchMediaTags(file: string | Blob, id: number): Thunk {
return async (dispatch, getState, { requireMusicMetadata }) => {
dispatch({ type: MEDIA_TAG_REQUEST_INITIALIZED, id });
try {
const metadata = await genMediaTags(file, await requireMusicMetadata());
// There's more data here, but we don't have a use for it yet:
const { artist, title, album, picture } = metadata.common;
const { numberOfChannels, bitrate, sampleRate } = metadata.format;
let albumArtUrl = null;
if (picture && picture.length >= 1) {
const byteArray = new Uint8Array(picture[0].data);
const blob = new Blob([byteArray], { type: picture[0].format });
albumArtUrl = URL.createObjectURL(blob);
}
dispatch({
type: SET_MEDIA_TAGS,
artist: artist ? artist : "",
title: title ? title : "",
album,
albumArtUrl,
numberOfChannels,
bitrate,
sampleRate,
id,
});
} catch (e) {
dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id });
}
};
}
export function setEqFromFileReference(fileReference: File): Thunk {
return async dispatch => {
const arrayBuffer = await genArrayBufferFromFileReference(fileReference);
const eqf = parser(arrayBuffer);
const preset: EqfPreset = eqf.presets[0];
dispatch(setEqFromObject(preset));
};
}
export function setEqFromObject(preset: EqfPreset): Thunk {
return dispatch => {
dispatch(setPreamp(Utils.normalizeEqBand(preset.preamp)));
BANDS.forEach(band => {
// @ts-ignore band and EqfPreset align
dispatch(setEqBand(band, Utils.normalizeEqBand(preset[`hz${band}`])));
});
};
}
export function downloadPreset(): Thunk {
return (dispatch, getState) => {
const state = getState();
const data = getEqfData(state);
const arrayBuffer = creator(data);
const base64 = Utils.base64FromArrayBuffer(arrayBuffer);
const dataURI = `data:application/zip;base64,${base64}`;
Utils.downloadURI(dataURI, "entry.eqf");
};
}
export function downloadHtmlPlaylist(): Thunk {
return (dispatch, getState) => {
const uri = getPlaylistURL(getState());
Utils.downloadURI(uri, "Winamp Playlist.html");
};
}