import jsmediatags from "jsmediatags/dist/jsmediatags"; import { parser, creator } from "winamp-eqf"; import { genArrayBufferFromFileReference, genArrayBufferFromUrl, promptForFileReferences } from "./fileUtils"; import skinParser from "./skinParser"; import { BANDS, TRACK_HEIGHT } from "./constants"; import { getEqfData, nextTrack, getScrollOffset, getOverflowTrackCount, getPlaylistURL, getSelectedTrackObjects } from "./selectors"; import { clamp, base64FromArrayBuffer, downloadURI, normalize, sort } from "./utils"; import { CLOSE_WINAMP, ADD_TRACK_FROM_URL, SEEK_TO_PERCENT_COMPLETE, SET_BALANCE, SET_BAND_VALUE, SET_SKIN_DATA, SET_VOLUME, STOP, TOGGLE_REPEAT, TOGGLE_SHUFFLE, SET_EQ_ON, SET_EQ_OFF, TOGGLE_EQUALIZER_SHADE_MODE, CLOSE_EQUALIZER_WINDOW, REMOVE_TRACKS, REMOVE_ALL_TRACKS, PLAY, PAUSE, REVERSE_LIST, RANDOMIZE_LIST, SET_TRACK_ORDER, TOGGLE_VISUALIZER_STYLE, PLAY_TRACK, BUFFER_TRACK, SET_PLAYLIST_SCROLL_POSITION, DRAG_SELECTED, SET_MEDIA_TAGS, SET_MEDIA_DURATION, TOGGLE_SHADE_MODE, TOGGLE_PLAYLIST_SHADE_MODE } from "./actionTypes"; function playRandomTrack() { return (dispatch, getState) => { const { playlist: { trackOrder, currentTrack } } = getState(); let nextId; do { nextId = trackOrder[Math.floor(trackOrder.length * Math.random())]; } while (nextId === currentTrack && trackOrder.length > 1); // TODO: Sigh... Technically, we should detect if we are looping only repeat if we are. // I think this would require pre-computing the "random" order of a playlist. dispatch({ type: PLAY_TRACK, id: nextId }); }; } export function play() { return { type: PLAY }; } export function pause() { return (dispatch, getState) => { const { status } = getState().media; dispatch({ type: status === "PLAYING" ? PAUSE : PLAY }); }; } export function stop() { return { type: STOP }; } export function nextN(n) { return (dispatch, getState) => { const state = getState(); if (state.media.shuffle) { dispatch(playRandomTrack()); return; } const nextTrackId = nextTrack(state, n); if (nextTrackId == null) { return; } dispatch({ type: PLAY_TRACK, id: nextTrackId }); }; } export function next() { return nextN(1); } export function previous() { return nextN(-1); } export function seekForward(seconds) { return function(dispatch, getState) { const { media } = getState(); const { timeElapsed, length } = media; const newTimeElapsed = timeElapsed + seconds; const newPercentComplete = newTimeElapsed / length; dispatch({ type: SEEK_TO_PERCENT_COMPLETE, percent: newPercentComplete }); }; } export function seekBackward(seconds) { return seekForward(-seconds); } export function close() { return dispatch => { dispatch({ type: STOP }); dispatch({ type: CLOSE_WINAMP }); }; } export function setVolume(volume) { return { type: SET_VOLUME, volume: clamp(volume, 0, 100) }; } export function adjustVolume(volumeDiff) { return (dispatch, getState) => { const currentVolume = getState().media.volume; return dispatch(setVolume(currentVolume + volumeDiff)); }; } export function scrollVolume(e) { e.preventDefault(); return (dispatch, getState) => { const currentVolume = getState().media.volume; // Using pixels as percentage difference here is a bit arbirary, but... oh well. return dispatch(setVolume(currentVolume + e.deltaY)); }; } export function setBalance(balance) { balance = clamp(balance, -100, 100); // The balance clips to the center if (Math.abs(balance) < 25) { balance = 0; } return { type: SET_BALANCE, balance }; } export function toggleRepeat() { return { type: TOGGLE_REPEAT }; } export function toggleShuffle() { return { type: TOGGLE_SHUFFLE }; } function setEqFromFileReference(fileReference) { return async dispatch => { const arrayBuffer = await genArrayBufferFromFileReference(fileReference); const eqf = parser(arrayBuffer); const preset = eqf.presets[0]; dispatch(setPreamp(normalize(preset.preamp))); BANDS.forEach(band => { dispatch(setEqBand(band, normalize(preset[`hz${band}`]))); }); }; } export function addTracksFromReferences(fileReferences, autoPlay, atIndex) { return dispatch => { 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 SKIN_FILENAME_MATCHER = new RegExp("(wsz|zip)$", "i"); const EQF_FILENAME_MATCHER = new RegExp("eqf$", "i"); export function loadFilesFromReferences( fileReferences, autoPlay = true, atIndex = null ) { return dispatch => { if (fileReferences.length < 1) { return; } else if (fileReferences.length === 1) { const fileReference = fileReferences[0]; if (SKIN_FILENAME_MATCHER.test(fileReference.name)) { dispatch(setSkinFromFileReference(fileReference)); return; } else if (EQF_FILENAME_MATCHER.test(fileReference.name)) { dispatch(setEqFromFileReference(fileReference)); return; } } dispatch(addTracksFromReferences(fileReferences, autoPlay, atIndex)); }; } 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.addEventListener("durationchange", durationChange); audio.src = url; }; } let counter = 0; function uniqueId() { return counter++; } function _addTrackFromUrl(url, name, id, priority, atIndex) { 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)); } }; } export function loadMediaFromUrl(url, name, priority) { return dispatch => { const id = uniqueId(); dispatch(_addTrackFromUrl(url, name, id, priority)); dispatch(fetchMediaTags(url, id)); }; } 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. } }); } 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. } }; } export function setSkinFromArrayBuffer(arrayBuffer) { return async dispatch => { const skinData = await skinParser(arrayBuffer); dispatch({ type: SET_SKIN_DATA, skinImages: skinData.images, skinColors: skinData.colors, skinPlaylistStyle: skinData.playlistStyle, skinCursors: skinData.cursors, skinRegion: skinData.region, skinGenLetterWidths: skinData.genLetterWidths }); }; } export function setSkinFromFileReference(skinFileReference) { return async dispatch => { const arrayBuffer = await genArrayBufferFromFileReference( skinFileReference ); dispatch(setSkinFromArrayBuffer(arrayBuffer)); }; } export function setSkinFromUrl(url) { return async dispatch => { const arrayBuffer = await genArrayBufferFromUrl(url); dispatch(setSkinFromArrayBuffer(arrayBuffer)); }; } export function openFileDialog() { return async dispatch => { const fileReferences = await promptForFileReferences(); dispatch(loadFilesFromReferences(fileReferences)); }; } export function setEqBand(band, value) { return { type: SET_BAND_VALUE, band, value }; } function _setEqTo(value) { return dispatch => { Object.keys(BANDS).forEach(key => { const band = BANDS[key]; dispatch({ type: SET_BAND_VALUE, value, band }); }); }; } export function setEqToMax() { return _setEqTo(100); } export function setEqToMid() { return _setEqTo(50); } export function setEqToMin() { return _setEqTo(0); } export function setPreamp(value) { return { type: SET_BAND_VALUE, band: "preamp", value }; } export function toggleEq() { return (dispatch, getState) => { const type = getState().equalizer.on ? SET_EQ_OFF : SET_EQ_ON; dispatch({ type }); }; } export function downloadPreset() { return (dispatch, getState) => { const state = getState(); const data = getEqfData(state); const arrayBuffer = creator(data); const base64 = base64FromArrayBuffer(arrayBuffer); const dataURI = `data:application/zip;base64,${base64}`; downloadURI(dataURI, "entry.eqf"); }; } export function toggleEqualizerShadeMode() { return { type: TOGGLE_EQUALIZER_SHADE_MODE }; } export function toggleMainWindowShadeMode() { return { type: TOGGLE_SHADE_MODE }; } export function togglePlaylistShadeMode() { return { type: TOGGLE_PLAYLIST_SHADE_MODE }; } export function closeEqualizerWindow() { return { type: CLOSE_EQUALIZER_WINDOW }; } export function cropPlaylist() { return (dispatch, getState) => { const state = getState(); if (getSelectedTrackObjects(state).length === 0) { return; } const { playlist: { tracks } } = getState(); dispatch({ type: REMOVE_TRACKS, ids: Object.keys(tracks).filter(id => !tracks[id].selected) }); }; } export function removeSelectedTracks() { return (dispatch, getState) => { const { playlist: { tracks } } = getState(); dispatch({ type: REMOVE_TRACKS, ids: Object.keys(tracks).filter(id => tracks[id].selected) }); }; } export function removeAllTracks() { return { type: REMOVE_ALL_TRACKS }; } export function reverseList() { return { type: REVERSE_LIST }; } export function randomizeList() { return { type: RANDOMIZE_LIST }; } export function sortListByTitle() { return (dispatch, getState) => { const state = getState(); const trackOrder = sort(state.playlist.trackOrder, i => `${state.playlist.tracks[i].title}`.toLowerCase() ); return dispatch({ type: SET_TRACK_ORDER, trackOrder }); }; } export function toggleVisualizerStyle() { return { type: TOGGLE_VISUALIZER_STYLE }; } export function setPlaylistScrollPosition(position) { return { type: SET_PLAYLIST_SCROLL_POSITION, position }; } export function scrollNTracks(n) { return (dispatch, getState) => { const state = getState(); const overflow = getOverflowTrackCount(state); const currentOffset = getScrollOffset(state); const position = overflow ? clamp((currentOffset + n) / overflow, 0, 1) : 0; return dispatch({ type: SET_PLAYLIST_SCROLL_POSITION, position: position * 100 }); }; } export function scrollPlaylistByDelta(e) { e.preventDefault(); return (dispatch, getState) => { const state = getState(); if (getOverflowTrackCount(state)) { e.stopPropagation(); } const totalPixelHeight = state.playlist.trackOrder.length * TRACK_HEIGHT; const percentDelta = e.deltaY / totalPixelHeight * 100; dispatch({ type: SET_PLAYLIST_SCROLL_POSITION, position: clamp( state.display.playlistScrollPosition + percentDelta, 0, 100 ) }); }; } export function scrollUpFourTracks() { return scrollNTracks(-4); } export function scrollDownFourTracks() { return scrollNTracks(4); } function findLastIndex(arr, cb) { for (let i = arr.length - 1; i >= 0; i--) { if (cb(arr[i])) { return i; } } return -1; } export function dragSelected(offset) { return (dispatch, getState) => { const { playlist: { trackOrder, tracks } } = getState(); const firstSelected = trackOrder.findIndex( trackId => tracks[trackId] && tracks[trackId].selected ); if (firstSelected === -1) { return; } const lastSelected = findLastIndex( trackOrder, trackId => tracks[trackId] && tracks[trackId].selected ); if (lastSelected === -1) { throw new Error("We found a first selected, but not a last selected."); } // Ensure we don't try to drag off either end. const min = -firstSelected; const max = trackOrder.length - 1 - lastSelected; const normalizedOffset = clamp(offset, min, max); if (normalizedOffset !== 0) { dispatch({ type: DRAG_SELECTED, offset: normalizedOffset }); } }; } export function downloadHtmlPlaylist() { return (dispatch, getState) => { const uri = getPlaylistURL(getState()); downloadURI(uri, "Winamp Playlist.html"); }; }