mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-21 17:18:52 +00:00
692 lines
18 KiB
JavaScript
692 lines
18 KiB
JavaScript
import { parser, creator } from "winamp-eqf";
|
|
import {
|
|
genArrayBufferFromFileReference,
|
|
genArrayBufferFromUrl,
|
|
promptForFileReferences,
|
|
genMediaDuration,
|
|
genMediaTags
|
|
} from "./fileUtils";
|
|
import skinParser from "./skinParser";
|
|
import { BANDS, TRACK_HEIGHT, LOAD_STYLE } from "./constants";
|
|
import {
|
|
getEqfData,
|
|
nextTrack,
|
|
getScrollOffset,
|
|
getOverflowTrackCount,
|
|
getPlaylistURL,
|
|
getSelectedTrackObjects,
|
|
getTracks,
|
|
getTrackIsVisibleFunction,
|
|
getWindowGraph,
|
|
getWindowPositions,
|
|
getWindowSizes
|
|
} from "./selectors";
|
|
|
|
import {
|
|
clamp,
|
|
base64FromArrayBuffer,
|
|
downloadURI,
|
|
normalize,
|
|
sort,
|
|
uniqueId
|
|
} 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_MAIN_SHADE_MODE,
|
|
TOGGLE_PLAYLIST_SHADE_MODE,
|
|
MEDIA_TAG_REQUEST_INITIALIZED,
|
|
MEDIA_TAG_REQUEST_FAILED,
|
|
PLAYLIST_SIZE_CHANGED,
|
|
UPDATE_WINDOW_POSITIONS,
|
|
TOGGLE_DOUBLESIZE_MODE
|
|
} from "./actionTypes";
|
|
|
|
import LoadQueue from "./loadQueue";
|
|
import { getPositionDiff } from "./resizeUtils";
|
|
import { applyDiff } from "./snapUtils";
|
|
|
|
// 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 });
|
|
|
|
function playRandomTrack() {
|
|
return (dispatch, getState) => {
|
|
const {
|
|
playlist: { trackOrder, currentTrack }
|
|
} = getState();
|
|
if (trackOrder.length === 0) {
|
|
return;
|
|
}
|
|
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 (dispatch, getState) => {
|
|
const state = getState();
|
|
if (
|
|
state.media.status === "STOPPED" &&
|
|
state.playlist.curentTrack == null &&
|
|
state.playlist.trackOrder.length === 0
|
|
) {
|
|
dispatch(openMediaFileDialog());
|
|
} else {
|
|
dispatch({ 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, loadStyle, atIndex) {
|
|
const tracks = 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,
|
|
loadStyle = LOAD_STYLE.PLAY,
|
|
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, loadStyle, atIndex));
|
|
};
|
|
}
|
|
|
|
export function fetchMediaDuration(url, id) {
|
|
return (dispatch, getState) => {
|
|
loadQueue.push(
|
|
() =>
|
|
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?
|
|
}),
|
|
() => {
|
|
const trackIsVisible = getTrackIsVisibleFunction(getState());
|
|
return trackIsVisible(id)
|
|
? DURATION_VISIBLE_PRIORITY
|
|
: DURATION_PRIORITY;
|
|
}
|
|
);
|
|
};
|
|
}
|
|
|
|
export function loadMediaFiles(tracks, loadStyle = null, atIndex = 0) {
|
|
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 != null ? loadStyle : null;
|
|
dispatch(loadMediaFile(track, priority, atIndex + i));
|
|
});
|
|
};
|
|
}
|
|
|
|
export function loadMediaFile(track, priority = null, atIndex = 0) {
|
|
return dispatch => {
|
|
const id = uniqueId();
|
|
const { url, blob, defaultName, metaData, duration } = track;
|
|
let canonicalUrl = url;
|
|
if (canonicalUrl == null) {
|
|
if (blob == null) {
|
|
throw new Error("Expected track to have either a blob or a url");
|
|
}
|
|
canonicalUrl = URL.createObjectURL(track.blob);
|
|
}
|
|
dispatch({
|
|
type: ADD_TRACK_FROM_URL,
|
|
url: canonicalUrl,
|
|
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;
|
|
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 if (blob != null) {
|
|
// Blobs can be loaded quickly
|
|
dispatch(fetchMediaTags(blob, id));
|
|
} else {
|
|
dispatch(queueFetchingMediaTags(id));
|
|
}
|
|
};
|
|
}
|
|
|
|
function queueFetchingMediaTags(id) {
|
|
return (dispatch, getState) => {
|
|
const track = getTracks(getState())[id];
|
|
return loadQueue.push(
|
|
() => dispatch(fetchMediaTags(track.url, id)),
|
|
() => {
|
|
const trackIsVisible = getTrackIsVisibleFunction(getState());
|
|
return trackIsVisible(track.id)
|
|
? META_DATA_VISIBLE_PRIORITY
|
|
: META_DATA_PRIORITY;
|
|
}
|
|
);
|
|
};
|
|
}
|
|
|
|
export function fetchMediaTags(file, id) {
|
|
return dispatch => {
|
|
dispatch({ type: MEDIA_TAG_REQUEST_INITIALIZED, id });
|
|
return genMediaTags(file)
|
|
.then(data => {
|
|
// There's more data here, but we don't have a use for it yet:
|
|
// https://github.com/aadsm/jsmediatags#shortcuts
|
|
const { artist, title, picture } = data.tags;
|
|
let albumArtUrl = null;
|
|
if (picture) {
|
|
const byteArray = new Uint8Array(picture.data);
|
|
const blob = new Blob([byteArray], { type: picture.type });
|
|
albumArtUrl = URL.createObjectURL(blob);
|
|
}
|
|
dispatch({ type: SET_MEDIA_TAGS, artist, title, albumArtUrl, id });
|
|
})
|
|
.catch(() => {
|
|
dispatch({ type: MEDIA_TAG_REQUEST_FAILED, id });
|
|
});
|
|
};
|
|
}
|
|
|
|
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));
|
|
};
|
|
}
|
|
|
|
// 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) {
|
|
return async dispatch => {
|
|
const fileReferences = await promptForFileReferences({ accept });
|
|
dispatch(loadFilesFromReferences(fileReferences));
|
|
};
|
|
}
|
|
|
|
export function openEqfFileDialog() {
|
|
return _openFileDialog(".eqf");
|
|
}
|
|
|
|
export function openMediaFileDialog() {
|
|
return _openFileDialog();
|
|
}
|
|
|
|
export function openSkinFileDialog() {
|
|
return _openFileDialog(".zip, .wsz");
|
|
}
|
|
|
|
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");
|
|
};
|
|
}
|
|
|
|
// Dispatch an action and, if needed rearrange the windows to preserve
|
|
// the existing edge relationship.
|
|
//
|
|
// Works by checking the edges before the action is dispatched. Then,
|
|
// after disatching, calculating what position change would be required
|
|
// to restore those relationships.
|
|
function withWindowGraphIntegrity(action) {
|
|
return (dispatch, getState) => {
|
|
const state = getState();
|
|
const graph = getWindowGraph(state);
|
|
const originalSizes = getWindowSizes(state);
|
|
|
|
dispatch(action);
|
|
|
|
const newSizes = getWindowSizes(getState());
|
|
const sizeDiff = {};
|
|
for (const window of Object.keys(newSizes)) {
|
|
const original = originalSizes[window];
|
|
const current = newSizes[window];
|
|
sizeDiff[window] = {
|
|
height: current.height - original.height,
|
|
width: current.width - original.width
|
|
};
|
|
}
|
|
|
|
const positionDiff = getPositionDiff(graph, sizeDiff);
|
|
const windowPositions = getWindowPositions(state);
|
|
|
|
const newPositions = {};
|
|
for (const key of Object.keys(windowPositions)) {
|
|
newPositions[key] = applyDiff(windowPositions[key], positionDiff[key]);
|
|
}
|
|
|
|
dispatch(updateWindowPositions(newPositions));
|
|
};
|
|
}
|
|
|
|
export function toggleDoubleSizeMode() {
|
|
return withWindowGraphIntegrity({ type: TOGGLE_DOUBLESIZE_MODE });
|
|
}
|
|
|
|
export function toggleEqualizerShadeMode() {
|
|
return withWindowGraphIntegrity({ type: TOGGLE_EQUALIZER_SHADE_MODE });
|
|
}
|
|
|
|
export function toggleMainWindowShadeMode() {
|
|
return withWindowGraphIntegrity({ type: TOGGLE_MAIN_SHADE_MODE });
|
|
}
|
|
|
|
export function togglePlaylistShadeMode() {
|
|
return withWindowGraphIntegrity({ 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 setPlaylistSize(size) {
|
|
return { type: PLAYLIST_SIZE_CHANGED, size };
|
|
}
|
|
|
|
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");
|
|
};
|
|
}
|
|
|
|
export function updateWindowPositions(positions) {
|
|
return { type: UPDATE_WINDOW_POSITIONS, positions };
|
|
}
|