webamp/js/selectors.ts
Jordan Eldredge 4a3929a2b5 Fix bug where we could not progress to the next track
This was because we would go into stopped mode, and once in stopped mode, going to the next track was disabled.
2019-03-27 23:56:31 -07:00

686 lines
18 KiB
TypeScript

import {
AppState,
PlaylistTrack,
WebampWindow,
WindowId,
WindowInfo,
LoadedURLTrack,
WindowPositions,
PlaylistStyle,
TransitionType,
MediaStatus,
} from "./types";
import { createSelector } from "reselect";
import * as Utils from "./utils";
import {
BANDS,
TRACK_HEIGHT,
WINDOW_RESIZE_SEGMENT_WIDTH,
WINDOW_RESIZE_SEGMENT_HEIGHT,
WINDOW_WIDTH,
MEDIA_STATUS,
MEDIA_TAG_REQUEST_STATUS,
WINDOWS,
VISUALIZERS,
} from "./constants";
import { createPlaylistURL } from "./playlistHtml";
import * as fromTracks from "./reducers/tracks";
import * as fromDisplay from "./reducers/display";
import * as fromEqualizer from "./reducers/equalizer";
import * as fromMedia from "./reducers/media";
import * as fromWindows from "./reducers/windows";
import * as TrackUtils from "./trackUtils";
import * as MarqueeUtils from "./marqueeUtils";
import { generateGraph } from "./resizeUtils";
import { SerializedStateV1 } from "./serializedStates/v1Types";
export const getSliders = (state: AppState) => state.equalizer.sliders;
export const getEqfData = createSelector(
getSliders,
sliders => {
const preset: { [key: string]: number | string } = {
name: "Entry1",
preamp: Utils.denormalizeEqBand(sliders.preamp),
};
BANDS.forEach(band => {
preset[`hz${band}`] = Utils.denormalizeEqBand(sliders[band]);
});
const eqfData = {
presets: [preset],
type: "Winamp EQ library file v1.1",
};
return eqfData;
}
);
export const getTracks = (state: AppState) => state.tracks;
export const getTracksMatchingFilter = createSelector(
getTracks,
tracks => {
const tracksArray = Object.values(tracks);
const filter = Utils.makeCachingFilterFunction(
tracksArray,
(track, query) => TrackUtils.trackFilterContents(track).includes(query)
);
return (filterString: string): PlaylistTrack[] => {
return filter(filterString.toLowerCase());
};
}
);
export const getTrackUrl = (state: AppState) => {
return (id: number): string | null => {
const track = state.tracks[id];
return track == null ? null : track.url;
};
};
export const getTrackOrder = (state: AppState) => state.playlist.trackOrder;
export const getTrackCount = createSelector(
getTrackOrder,
trackOrder => trackOrder.length
);
export const getOrderedTracks = createSelector(
getTracks,
getTrackOrder,
(tracks, trackOrder) => trackOrder.filter(id => tracks[id])
);
const getOrderedTrackObjects = createSelector(
getTracks,
getOrderedTracks,
(tracks, trackOrder): PlaylistTrack[] => trackOrder.map(id => tracks[id])
);
export const getSelectedTrackIds = (state: AppState): Set<number> => {
return state.playlist.selectedTracks;
};
export const getSelectedTrackObjects = createSelector(
getOrderedTrackObjects,
getSelectedTrackIds,
(tracks, selectedIds) => tracks.filter(track => selectedIds.has(track.id))
);
// If a duration is `null`, it counts as zero, which seems fine enough.
const runningTimeFromTracks = (tracks: PlaylistTrack[]) =>
tracks.reduce((time, track) => time + Number(track.duration), 0);
const getTotalRunningTime = createSelector(
getOrderedTrackObjects,
runningTimeFromTracks
);
const getSelectedRunningTime = createSelector(
getSelectedTrackObjects,
runningTimeFromTracks
);
// Note: We should append "+" to these values if some of the tracks are of unknown time.
export const getRunningTimeMessage = createSelector(
getTotalRunningTime,
getSelectedRunningTime,
(totalRunningTime, selectedRunningTime) =>
`${Utils.getTimeStr(selectedRunningTime)}/${Utils.getTimeStr(
totalRunningTime
)}`
);
// TODO: use slectors to get memoization
export const getCurrentTrackIndex = (state: AppState): number => {
const { playlist } = state;
if (playlist.currentTrack == null) {
return -1;
}
return playlist.trackOrder.indexOf(playlist.currentTrack);
};
export const getCurrentTrackNumber = createSelector(
getCurrentTrackIndex,
currentTrackIndex => currentTrackIndex + 1
);
export const getCurrentTrackId = (state: AppState) =>
state.playlist.currentTrack;
// 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.
export const getRandomTrackId = (state: AppState): number | null => {
const {
playlist: { trackOrder, currentTrack },
} = state;
if (trackOrder.length === 0) {
return null;
}
let nextId;
do {
nextId = trackOrder[Math.floor(trackOrder.length * Math.random())];
} while (nextId === currentTrack && trackOrder.length > 1);
return nextId;
};
export const getNextTrackId = (state: AppState, n = 1) => {
const {
playlist: { trackOrder },
media: { repeat, shuffle },
} = state;
if (shuffle) {
return getRandomTrackId(state);
}
const trackCount = getTrackCount(state);
if (trackCount === 0) {
return null;
}
const currentIndex = getCurrentTrackIndex(state);
let nextIndex = currentIndex + n;
if (repeat) {
nextIndex = nextIndex % trackCount;
if (nextIndex < 0) {
// Handle wrapping around backwards
nextIndex += trackCount;
}
return trackOrder[nextIndex];
}
if (currentIndex === trackCount - 1 && n > 0) {
return null;
} else if (currentIndex === 0 && n < 0) {
return null;
}
nextIndex = Utils.clamp(nextIndex, 0, trackCount - 1);
return trackOrder[nextIndex];
};
const BASE_WINDOW_HEIGHT = 58;
export const getNumberOfVisibleTracks = (state: AppState) => {
const playlistSize = getWindowSize(state)("playlist");
return Math.floor(
(BASE_WINDOW_HEIGHT + WINDOW_RESIZE_SEGMENT_HEIGHT * playlistSize[1]) /
TRACK_HEIGHT
);
};
export const getOverflowTrackCount = createSelector(
getTrackCount,
getNumberOfVisibleTracks,
(trackCount, numberOfVisibleTracks) =>
Math.max(0, trackCount - numberOfVisibleTracks)
);
const _getPlaylistScrollPosition = (state: AppState) =>
state.display.playlistScrollPosition;
export const getPlaylistScrollPosition = createSelector(
getOverflowTrackCount,
_getPlaylistScrollPosition,
(overflowTrackCount, playlistScrollPosition) => {
if (overflowTrackCount === 0) {
return 0;
}
return Math.round(
(Math.round((overflowTrackCount * playlistScrollPosition) / 100) /
overflowTrackCount) *
100
);
}
);
export const getScrollOffset = createSelector(
_getPlaylistScrollPosition,
getTrackCount,
getNumberOfVisibleTracks,
(playlistScrollPosition, trackCount, numberOfVisibleTracks) => {
const overflow = Math.max(0, trackCount - numberOfVisibleTracks);
return Utils.percentToIndex(playlistScrollPosition / 100, overflow + 1);
}
);
export const getVisibleTrackIds = createSelector(
getScrollOffset,
getTrackOrder,
getNumberOfVisibleTracks,
(offset, trackOrder, numberOfVisibleTracks) =>
trackOrder.slice(offset, offset + numberOfVisibleTracks)
);
export const getTrackIsVisibleFunction = createSelector(
getVisibleTrackIds,
visibleTrackIds => {
return (id: number) => visibleTrackIds.includes(id);
}
);
export const getVisibleTracks = createSelector(
getVisibleTrackIds,
getTracks,
(visibleTrackIds, tracks) => visibleTrackIds.map(id => tracks[id])
);
export const getPlaylist = (state: AppState) => state.playlist;
export const getDuration = (state: AppState): number | null => {
const { playlist, tracks } = state;
if (playlist.currentTrack == null) {
return null;
}
const currentTrack = tracks[playlist.currentTrack];
return currentTrack && currentTrack.duration;
};
export const getTrackDisplayName = createSelector(
getTracks,
tracks => {
return (trackId: number | null) =>
fromTracks.getTrackDisplayName(tracks, trackId);
}
);
export const getCurrentTrackDisplayName = createSelector(
getCurrentTrackId,
getTrackDisplayName,
(id, getName) => {
return getName(id);
}
);
export const getMediaStatus = (state: AppState): MediaStatus | null => {
return state.media.status;
};
export const getMediaIsPlaying = (state: AppState) =>
state.media.status === MEDIA_STATUS.PLAYING;
export const getCurrentTrack = createSelector(
getCurrentTrackId,
getTracks,
(trackId, tracks): PlaylistTrack | null => {
return trackId == null ? null : tracks[trackId];
}
);
export const getCurrentlyPlayingTrackIdIfLoaded = createSelector(
getMediaIsPlaying,
getCurrentTrack,
(mediaIsPlaying, currentTrack) => {
if (
!mediaIsPlaying ||
!currentTrack ||
currentTrack.mediaTagsRequestStatus ===
MEDIA_TAG_REQUEST_STATUS.INITIALIZED
) {
return null;
}
return currentTrack.id;
}
);
export const getCurrentTrackInfo = createSelector(
getCurrentTrack,
(track: PlaylistTrack | null): LoadedURLTrack | null => {
if (track == null) {
return null;
}
return {
url: track.url,
metaData: {
title: track.title || null,
artist: track.artist || null,
album: track.album || null,
albumArtUrl: track.albumArtUrl || null,
},
};
}
);
export const getMinimalMediaText = createSelector(
getCurrentTrackNumber,
getCurrentTrackDisplayName,
(trackNumber, name) => (name == null ? null : `${trackNumber}. ${name}`)
);
export const getMediaText = createSelector(
getMinimalMediaText,
getDuration,
(minimalMediaText, duration) =>
minimalMediaText == null
? null
: `${minimalMediaText} (${Utils.getTimeStr(duration)})`
);
export const getNumberOfTracks = (state: AppState) =>
getTrackOrder(state).length;
const getPlaylistDuration = createSelector(
getTracks,
tracks =>
Object.values(tracks).reduce(
(total, track) => total + (track.duration || 0),
0
)
);
export const getPlaylistURL = createSelector(
getNumberOfTracks,
getPlaylistDuration,
getTrackOrder,
getTracks,
getTrackDisplayName,
(numberOfTracks, playlistDuration, trackOrder, tracks, getDisplayName) =>
createPlaylistURL({
numberOfTracks,
averageTrackLength: Utils.getTimeStr(playlistDuration / numberOfTracks),
// TODO: Handle hours
playlistLengthMinutes: Math.floor(playlistDuration / 60),
playlistLengthSeconds: Math.floor(playlistDuration % 60),
tracks: trackOrder.map(
(id, i) =>
`${i + 1}. ${getDisplayName(id)} (${Utils.getTimeStr(
tracks[id].duration
)})`
),
})
);
const WINDOW_HEIGHT = 116;
const SHADE_WINDOW_HEIGHT = 14;
function getWPixelSize(w: WebampWindow, doubled: boolean) {
const [width, height] = w.size;
const doubledMultiplier = doubled && w.canDouble ? 2 : 1;
const pix = {
height: WINDOW_HEIGHT + height * WINDOW_RESIZE_SEGMENT_HEIGHT,
width: WINDOW_WIDTH + width * WINDOW_RESIZE_SEGMENT_WIDTH,
};
return {
height: (w.shade ? SHADE_WINDOW_HEIGHT : pix.height) * doubledMultiplier,
width: pix.width * doubledMultiplier,
};
}
export function getWindowSize(state: AppState) {
return (windowId: WindowId) => state.windows.genWindows[windowId].size;
}
export function getWindowOpen(state: AppState) {
return (windowId: WindowId) => state.windows.genWindows[windowId].open;
}
export function getWindowShade(state: AppState) {
return (windowId: WindowId) => state.windows.genWindows[windowId].shade;
}
export function getWindowHidden(state: AppState) {
return (windowId: WindowId) => state.windows.genWindows[windowId].hidden;
}
export function getFocusedWindow(state: AppState): WindowId {
return state.windows.focused;
}
export function getWindowPosition(state: AppState) {
return (windowId: WindowId) => state.windows.genWindows[windowId].position;
}
export function getPositionsAreRelative(state: AppState) {
return state.windows.positionsAreRelative;
}
export const getGenWindows = (state: AppState) => {
return state.windows.genWindows;
};
export const getWindowPositions = createSelector(
getGenWindows,
(windows): WindowPositions => Utils.objectMap(windows, w => w.position)
);
export function getDoubled(state: AppState) {
return state.display.doubled;
}
export function getLlamaMode(state: AppState) {
return state.display.llama;
}
export const getWindowSizes = createSelector(
getGenWindows,
getDoubled,
(windows, doubled) => {
return Utils.objectMap(windows, w => getWPixelSize(w, doubled));
}
);
export const getWindowPixelSize = createSelector(
getWindowSizes,
sizes => {
return (windowId: WindowId) => sizes[windowId];
}
);
// TODO: Now that both size and position are stored on genWindows this seems a bit silly.
export const getWindowsInfo = createSelector(
getWindowSizes,
getWindowPositions,
(sizes, positions): WindowInfo[] =>
Object.keys(sizes).map(key => ({ key, ...sizes[key], ...positions[key] }))
);
export const getWindowGraph = createSelector(
getWindowsInfo,
generateGraph
);
export const getSkinPlaylistStyle = (state: AppState): PlaylistStyle => {
return (
state.display.skinPlaylistStyle || {
normal: "#00FF00",
current: "#FFFFFF",
normalbg: "#000000",
selectedbg: "#0000C6",
font: "Arial",
}
);
};
export const getSkinGenExColors = (state: AppState) => {
return state.display.skinGenExColors;
};
export const getVisualizerStyle = (state: AppState): string => {
const milkdrop = state.windows.genWindows[WINDOWS.MILKDROP];
if (milkdrop != null && milkdrop.open) {
return VISUALIZERS.MILKDROP;
}
return fromDisplay.getVisualizerStyle(state.display);
};
export const getVolume = (state: AppState) => state.media.volume;
export const getBalance = (state: AppState) => state.media.balance;
export const getShuffle = (state: AppState) => state.media.shuffle;
export const getRepeat = (state: AppState) => state.media.repeat;
export const getChannels = createSelector(
getCurrentTrack,
(track: PlaylistTrack | null): number | null => {
return track != null ? track.channels || null : null;
}
);
export const getTimeElapsed = (state: AppState): number => {
return state.media.timeElapsed;
};
export function getSerlializedState(state: AppState): SerializedStateV1 {
return {
version: 1,
media: fromMedia.getSerializedState(state.media),
equalizer: fromEqualizer.getSerializedState(state.equalizer),
display: fromDisplay.getSerializedState(state.display),
windows: fromWindows.getSerializedState(state.windows),
};
}
export function getEqualizerEnabled(state: AppState): boolean {
return state.equalizer.on;
}
export function getEqualizerAuto(state: AppState): boolean {
return state.equalizer.auto;
}
export function getBrowserWindowSize(
state: AppState
): { height: number; width: number } {
return state.windows.browserWindowSize;
}
export const getOpenWindows = createSelector(
getGenWindows,
genWindows => Utils.objectFilter(genWindows, w => w.open)
);
export const getStackedLayoutPositions = createSelector(
getOpenWindows,
getDoubled,
(openWindows, doubled): WindowPositions => {
let offset = 0;
return Utils.objectMap(openWindows, w => {
const position = { x: 0, y: offset };
offset += getWPixelSize(w, doubled).height;
return position;
});
}
);
export const getUserInputFocus = (state: AppState): string | null => {
return state.userInput.focus;
};
export const getUserInputScrubPosition = (state: AppState): number => {
return state.userInput.scrubPosition;
};
// TODO: Make this a reselect selector
export const getMarqueeText = (state: AppState): string => {
const defaultText = "Winamp 2.91";
if (state.userInput.userMessage != null) {
return state.userInput.userMessage;
}
switch (getUserInputFocus(state)) {
case "balance":
return MarqueeUtils.getBalanceText(state.media.balance);
case "volume":
return MarqueeUtils.getVolumeText(state.media.volume);
case "position":
const duration = getDuration(state);
if (duration == null) {
// This probably can't ever happen.
return defaultText;
}
return MarqueeUtils.getPositionText(
duration,
getUserInputScrubPosition(state)
);
case "double":
return MarqueeUtils.getDoubleSizeModeText(state.display.doubled);
case "eq":
const band = state.userInput.bandFocused;
if (band == null) {
// This probably can't ever happen.
return defaultText;
}
return MarqueeUtils.getEqText(band, state.equalizer.sliders[band]);
default:
break;
}
if (state.playlist.currentTrack != null) {
const mediaText = getMediaText(state);
if (mediaText == null) {
// This probably can't ever happen.
return defaultText;
}
return mediaText;
}
return defaultText;
};
export const getKbps = createSelector(
getCurrentTrack,
(track: PlaylistTrack | null): string | null => {
return track != null ? track.kbps || null : null;
}
);
export const getKhz = createSelector(
getCurrentTrack,
(track: PlaylistTrack | null): string | null => {
return track != null ? track.khz || null : null;
}
);
export function getDebugData(state: AppState) {
return {
...state,
display: {
...state.display,
skinGenLetterWidths: "[[REDACTED]]",
skinImages: "[[REDACTED]]",
skinCursors: "[[REDACTED]]",
skinRegion: "[[REDACTED]]",
},
};
}
export function getMilkdropDesktopEnabled(state: AppState): boolean {
return state.milkdrop.display === "DESKTOP";
}
export function getMilkdropFullscreenEnabled(state: AppState): boolean {
return state.milkdrop.display === "FULLSCREEN";
}
export function getPresets(state: AppState): any {
return state.milkdrop.presets;
}
export function getButterchurn(state: AppState): any {
return state.milkdrop.butterchurn;
}
export function getPresetTransitionType(state: AppState): TransitionType {
return state.milkdrop.transitionType;
}
export function getCurrentPresetIndex(state: AppState): number | null {
return state.milkdrop.currentPresetIndex;
}
export function getCurrentPreset(state: AppState): any | null {
const index = getCurrentPresetIndex(state);
if (index == null) {
return null;
}
const preset = state.milkdrop.presets[index];
if (preset == null || preset.type === "UNRESOLVED") {
return null;
}
return preset.preset;
}
export function getPresetNames(state: AppState): string[] {
return state.milkdrop.presets.map(preset => preset.name);
}
export function getPresetOverlayOpen(state: AppState): boolean {
return state.milkdrop.overlay;
}
export function getPresetsAreCycling(state: AppState): boolean {
return state.milkdrop.cycling;
}
export function getRandomizePresets(state: AppState): boolean {
return state.milkdrop.randomize;
}