Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shuffle visualization preset feature #1555 #1612

Merged
merged 7 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/app/app/actions/visualizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Settings } from './actionTypes';
import butterchurnPresets from 'butterchurn-presets';

export function randomizePreset() {
const presetNames = Object.keys(butterchurnPresets.getPresets());
const randomPreset = presetNames[Math.floor(Math.random() * presetNames.length)];
return dispatch => {
dispatch({
type: Settings.SET_STRING_OPTION,
payload: {
option: 'visualizer.preset',
state: randomPreset
}
});
};
}
87 changes: 87 additions & 0 deletions packages/app/app/containers/SoundContainer/SoundContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react';
import { waitFor } from '@testing-library/react';

import { AnyProps, mountComponent } from '../../../test/testUtils';
import { buildStoreState } from '../../../test/storeBuilders';
import SoundContainer from '.';

jest.mock('react-hls-player', () => {
return {
__esModule: true,
default: () => <div />
};
});

jest.mock('react-hifi', () => {
let _finishPlayingCallback: () => void;

const MockReactHifi = ({ onFinishedPlaying }: { onFinishedPlaying: () => void }) => {
_finishPlayingCallback = onFinishedPlaying;
return null;
};

MockReactHifi.status = {
PAUSED: 'PAUSED'
};

const _default = jest.fn().mockImplementation(MockReactHifi);
(_default as unknown as ReactHiFiMock['default']).status = {
PAUSED: 'PAUSED'
};

return {
...jest.requireActual('react-hifi'),
__esModule: true,
default: _default,
finishPlaying: () => {
if (_finishPlayingCallback) {
_finishPlayingCallback();
}
},
finishPlayingCallback: () => _finishPlayingCallback
};
});

type ReactHiFiMock = {
default: React.FC<{ onFinishedPlaying: () => void }> & {
status: {
PAUSED: 'PAUSED'
}
};
finishPlaying: () => void;
finishPlayingCallback: () => void;
}

describe('Sound Container - visualizer shuffle functionality', () => {
it('Should change visualizer preset on song end, if the setting is enabled', async () => {
const { store, component } = mountSoundContainer();
let state = store.getState();
const reactHifiMock = (await import('react-hifi')) as unknown as ReactHiFiMock;

await waitFor(() => expect(state.settings['visualizer.preset']).toBe('test preset'));

await waitFor(async () => expect(reactHifiMock.finishPlayingCallback()).toBeDefined());
await waitFor(async () => expect(reactHifiMock.finishPlaying).toBeDefined());

reactHifiMock.finishPlaying();

state = store.getState();
await waitFor(() => expect(state.settings['visualizer.preset']).not.toBe('test preset'), { timeout: 2000 });
});

const mountSoundContainer = (initialStore?: AnyProps) => {
const initialState = initialStore ?? {
...buildStoreState()
.withTracksInPlayQueue()
.withPlayer({playbackStatus: 'PLAYING'})
.withSettings({
skipSponsorblock: true,
['visualizer.shuffle']: true,
['visualizer.preset']: 'test preset'
})
.build()
};

return mountComponent(<SoundContainer />, ['/'], initialState);
};
});
10 changes: 8 additions & 2 deletions packages/app/app/containers/SoundContainer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as EqualizerActions from '../../actions/equalizer';
import * as QueueActions from '../../actions/queue';
import * as ScrobblingActions from '../../actions/scrobbling';
import * as LyricsActions from '../../actions/lyrics';
import * as VisualizerActions from '../../actions/visualizer';
import { filterFrequencies } from '../../components/Equalizer/chart';
import * as Autoradio from './autoradio';
import VisualizerContainer from '../../containers/VisualizerContainer';
Expand Down Expand Up @@ -80,6 +81,10 @@ class SoundContainer extends React.Component {
}

handleFinishedPlaying() {
if (this.props.settings['visualizer.shuffle']) {
this.props.actions.randomizePreset();
}

const currentSong = this.props.queue.queueItems[
this.props.queue.currentSong
];
Expand Down Expand Up @@ -197,7 +202,7 @@ class SoundContainer extends React.Component {
const currentTrack = queue.queueItems[queue.currentSong];
const usedEqualizer = enableSpectrum ? equalizer : defaultEqualizer;

return Boolean(currentStream) && (this.isHlsStream(head(currentStream.streams)) ? (
return Boolean(currentStream) && (this.isHlsStream(currentStream.stream) ? (
<HlsPlayer
source={currentStream.stream}
onError={this.handleError}
Expand Down Expand Up @@ -266,7 +271,8 @@ function mapDispatchToProps(dispatch) {
QueueActions,
ScrobblingActions,
LyricsActions,
EqualizerActions
EqualizerActions,
VisualizerActions
),
dispatch
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {waitFor} from '@testing-library/react';
import VisualizerContainer from '.';
import { mountedComponentFactory } from '../../../test/testUtils';
import { buildStoreState } from '../../../test/storeBuilders';

describe('Visualizer Overlay - shuffle activation', () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { store } = require('@nuclear/core');
store.clear();
});

it('should enable shuffle when disabled and the button is clicked', async () => {
const { component, store } = mountComponent({
settings: {
['visualizer.shuffle']: false
}
});
await waitFor(() => component.getByTestId(/shuffle-button/i).click());
const state = store.getState();
expect(state.settings['visualizer.shuffle']).toEqual(true);
});

it('should disable shuffle when enabled and the button is clicked', async () => {
const { component, store } = mountComponent({
settings: {
['visualizer.shuffle']: true
}
});
await waitFor(() => component.getByTestId(/shuffle-button/i).click());
const state = store.getState();
expect(state.settings['visualizer.shuffle']).toEqual(false);
});

const mountComponent = mountedComponentFactory(
['/visualizer'],
buildStoreState()
.build(),
VisualizerContainer
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { VisualizerOverlay } from '@nuclear/ui';
import { useTranslation } from 'react-i18next';

import styles from './styles.scss';
import { useVisualizerOverlayProps, useVisualizerProps } from '../hooks';
import { useVisualizerOverlayProps, useVisualizerProps, useVisualizerShuffleProps, useVisualizerOverlayShuffleProps} from '../hooks';

const VisualizerNode: React.FC = () => {
const handle = useFullScreenHandle();
const presets = butterchurnPresets.getPresets();
const { t } = useTranslation('visualizer');
const { presetName } = useVisualizerProps();
const { onPresetChange } = useVisualizerOverlayProps();

const { shuffleValue } = useVisualizerShuffleProps();
const { onShuffleChange } = useVisualizerOverlayShuffleProps();
// The id is a hack to allow the visualizer to render in a portal in the correct place
return <FullScreen
className={styles.visualizer_fullscreen}
Expand All @@ -27,6 +28,8 @@ const VisualizerNode: React.FC = () => {
presets={Object.keys(presets)}
selectedPreset={presetName}
onPresetChange={onPresetChange}
shuffleState={shuffleValue}
onShuffleChange={onShuffleChange}
onEnterFullscreen={handle.enter}
exitFullscreenLabel={t('exit-fullscreen')}
isFullscreen={handle.active}
Expand Down
25 changes: 24 additions & 1 deletion packages/app/app/containers/VisualizerContainer/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import { setStringOption } from '../../actions/settings';
import { setBooleanOption, setStringOption } from '../../actions/settings';
import { settingsSelector } from '../../selectors/settings';

export const useVisualizerProps = () => {
Expand All @@ -20,3 +20,26 @@ export const useVisualizerOverlayProps = () => {
)), [dispatch]);
return { onPresetChange };
};

export const useVisualizerShuffleProps = () => {
const settings = useSelector(settingsSelector);
return {
shuffleValue: settings['visualizer.shuffle'] as boolean
};
};

export const useVisualizerOverlayShuffleProps = () => {
const dispatch = useDispatch();
const onShuffleChange = useCallback(() => {
dispatch((dispatch, getState) => {
const currentState = settingsSelector(getState());
const currentShuffleValue = currentState['visualizer.shuffle'];
dispatch(setBooleanOption(
'visualizer.shuffle',
!currentShuffleValue,
false
));
});
}, [dispatch]);
return {onShuffleChange};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import butterchurnPresets from 'butterchurn-presets';
import { setStringOption } from '../../actions/settings';

export const randomizePreset = (dispatch) => {
const presets = butterchurnPresets.getPresets();
const randomIndex = Math.floor(Math.random() * Object.keys(presets).length);
dispatch(setStringOption(
'visualizer.preset',
Object.keys(presets)[randomIndex],
false
));
};
2 changes: 1 addition & 1 deletion packages/app/app/reducers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { combineReducers } from 'redux';
import { StateType } from 'typesafe-actions';

import ConnectivityReducer from './connectivity';
import DashboardReducer from './dashboard';
import DownloadsReducer from './downloads';
import EqualizerReducer from './equalizer';
Expand All @@ -17,7 +18,6 @@ import SearchReducer from './search';
import SettingsReducer from './settings';
import TagReducer from './tag';
import ToastsReducer from './toasts';
import ConnectivityReducer from './connectivity';
import { reducer as LocalLibraryReducer } from './local';
import { reducer as MastodonReducer } from './mastodon';
import { reducer as NuclearReducer } from './nuclear';
Expand Down
20 changes: 11 additions & 9 deletions packages/app/app/reducers/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,23 @@ type PlayerReducerState = {
playbackRate: number
}

const initialState: PlayerReducerState = {
playbackStatus: Sound.status.PAUSED,
playbackStreamLoading: false,
playbackProgress: 0,
seek: 0,
volume: getOption('volume'),
muted: false,
playbackRate: 2
const initialState: () => PlayerReducerState = () => {
return ({
playbackStatus: Sound.status.PAUSED,
playbackStreamLoading: false,
playbackProgress: 0,
seek: 0,
volume: getOption('volume'),
muted: false,
playbackRate: 2
});
};

const actions = { nextSongAction, previousSongAction, selectSong, ...PlayerActions };

type PlayerReducerActions = ActionType<typeof actions>

export default function PlayerReducer(state=initialState, action: PlayerReducerActions): PlayerReducerState {
export default function PlayerReducer(state=initialState(), action: PlayerReducerActions): PlayerReducerState {
switch (action.type) {
case getType(PlayerActions.startPlayback):
return Object.assign({}, state, {
Expand Down
7 changes: 7 additions & 0 deletions packages/app/test/storeBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,13 @@ export const buildStoreState = () => {
};
return this as StoreStateBuilder;
},
withPlayer(playerState: Partial<RootState['player']>) {
state.player = {
...state.player,
...playerState
};
return this as StoreStateBuilder;
},
build() {
return state;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/lib/components/Button/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ button.nuclear.ui.button {
}
}

&.toggled {
background: darken(mix($bglight, $bgdark, 90%), 7.5%) !important;
}

$colors: ("green", "blue", "purple", "pink", "orange", "red");
$colorvars: (
"green": $green,
Expand Down
18 changes: 17 additions & 1 deletion packages/ui/lib/components/VisualizerOverlay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type VisualizerOverlayProps = {
presets: string[];
selectedPreset: string;
onPresetChange: (e: SyntheticEvent, { value }: { value: string }) => void;
shuffleState: boolean;
onShuffleChange: () => void;
onEnterFullscreen: React.MouseEventHandler;
exitFullscreenLabel?: string;
isFullscreen?: boolean;
Expand All @@ -17,6 +19,8 @@ const VisualizerOverlay: React.FC<VisualizerOverlayProps> = ({
selectedPreset,
onPresetChange,
onEnterFullscreen,
shuffleState,
onShuffleChange,
exitFullscreenLabel,
isFullscreen = false
}) => {
Expand Down Expand Up @@ -45,6 +49,11 @@ const VisualizerOverlay: React.FC<VisualizerOverlayProps> = ({
value: preset
}));

const handleClickNoFocus = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>, handler: () => void) => {
handler();
event.currentTarget.blur();
};

return (
<div
className={cx(styles.visualizer_overlay, { [styles.hover]: isHovered })}
Expand All @@ -56,9 +65,16 @@ const VisualizerOverlay: React.FC<VisualizerOverlayProps> = ({
search
selection
options={presetOptions}
defaultValue={selectedPreset}
value={selectedPreset}
onChange={onPresetChange}
/>
<Button
basic
icon='shuffle'
className={cx({ 'toggled': shuffleState })}
onClick={(e) => handleClickNoFocus(e, onShuffleChange)}
data-testid='shuffle-button'
/>
{isFullscreen ? (
<p>{exitFullscreenLabel}</p>
) : (
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/lib/components/VisualizerOverlay/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@

button {
margin: 0 !important;
&:last-child{
margin-left: 1em !important;
}
}

p {
Expand Down
Loading
Loading