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): 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, 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"); }; }