diff --git a/.parcelrc b/.parcelrc index b25ef06..c1d303c 100644 --- a/.parcelrc +++ b/.parcelrc @@ -2,6 +2,7 @@ "extends": "@parcel/config-default", "transformers": { "*.{ts,tsx}": ["@futureportal/parcel-transformer-package-version", "@parcel/transformer-typescript-tsc"], - "*.svg": ["@parcel/transformer-svg-react"] + "*.svg": ["@parcel/transformer-svg-react"], + "*.mp3": ["@parcel/transformer-raw"] } } diff --git a/docs/config/query-parameters.md b/docs/config/query-parameters.md index a3724ae..e4082ae 100644 --- a/docs/config/query-parameters.md +++ b/docs/config/query-parameters.md @@ -9,6 +9,7 @@ query parameters are available for you. | --------- | ------------------------------------------- | ------------------------ | | completed | Show completed CI steps (hidden by default) | `1` to show, `0` to hide | | avatars | Show user avatars | `1` to show, `0` to hide | +| sound | Enable warning/error/success sounds | `1` for on, `0` for off | ## How to use them diff --git a/frontend/App/AppContext.ts b/frontend/App/AppContext.ts index 88ca7a9..5e86138 100644 --- a/frontend/App/AppContext.ts +++ b/frontend/App/AppContext.ts @@ -3,11 +3,7 @@ import { createContext } from 'react'; type AppContext = { showCompleted: boolean | null; showAvatars: boolean | null; -}; - -const defaultContext: AppContext = { - showCompleted: null, - showAvatars: null, + sound: boolean | null; }; export const getQueryContext = (): AppContext => { @@ -28,9 +24,12 @@ export const getQueryContext = (): AppContext => { return { showCompleted: isEnabled('completed'), showAvatars: isEnabled('avatars'), + sound: isEnabled('sound'), }; }; +const defaultContext: AppContext = getQueryContext(); + const appContext = createContext(defaultContext); export default appContext; diff --git a/frontend/App/Favicon/Favicon.tsx b/frontend/App/Favicon/Favicon.tsx index d7156c8..4da1a4e 100644 --- a/frontend/App/Favicon/Favicon.tsx +++ b/frontend/App/Favicon/Favicon.tsx @@ -9,16 +9,16 @@ import WarningIcon from './icon/warning.png'; import { State } from '/types/status'; -const getIcon = (state: State) => { +const getIcon = (state: State): string => { if (state === 'error') { - return ErrorIcon; + return ErrorIcon as string; } if (state === 'warning') { - return WarningIcon; + return WarningIcon as string; } - return SuccessIcon; + return SuccessIcon as string; }; const Favicon = (): ReactElement => { diff --git a/frontend/App/SettingsPanel/Customization/Customization.tsx b/frontend/App/SettingsPanel/Customization/Customization.tsx index 6d69a7f..e5b5495 100644 --- a/frontend/App/SettingsPanel/Customization/Customization.tsx +++ b/frontend/App/SettingsPanel/Customization/Customization.tsx @@ -7,17 +7,37 @@ import { Content } from '/frontend/App/SettingsPanel/SettingsPanel.style'; import Icon from '/frontend/components/Icon'; import Modifier from '/frontend/components/Modifier'; import Toggle from '/frontend/components/Toggle'; -import { setSizeModifier, toggleShowCompleted, toggleShowUserAvatars } from '/frontend/store/settings/actions'; -import { getSizeModifier, isHidingUserAvatars, isShowingCompleted } from '/frontend/store/settings/selectors'; +import Sounds from '/frontend/sounds/Sounds'; +import { + setSizeModifier, + toggleShowCompleted, + toggleShowUserAvatars, + toggleSound, +} from '/frontend/store/settings/actions'; +import { + getSizeModifier, + isHidingUserAvatars, + isShowingCompleted, + isSoundEnabled, +} from '/frontend/store/settings/selectors'; const Customization = (): ReactElement => { const showCompleted = useSelector(isShowingCompleted); const sizeModifier = useSelector(getSizeModifier); + const soundEnabled = useSelector(isSoundEnabled); const isHidingAvatars = useSelector(isHidingUserAvatars); const dispatch = useDispatch(); const server = ; + const handleSoundToggle = () => { + if (!soundEnabled) { + Sounds.playSuccess(); + } + + dispatch(toggleSound()); + }; + return (

@@ -35,6 +55,17 @@ const Customization = (): ReactElement => { dispatch(toggleShowCompleted())} enabled={showCompleted} /> + + + Enable sounds + + Do you want to hear a status sound when a status starts, finishes or fails? + + + + + + Hide user avatars diff --git a/frontend/components/Toggle/Toggle.style.tsx b/frontend/components/Toggle/Toggle.style.tsx index d612c4b..3471f95 100644 --- a/frontend/components/Toggle/Toggle.style.tsx +++ b/frontend/components/Toggle/Toggle.style.tsx @@ -4,8 +4,8 @@ import { stateColor, textMutedColor } from '/frontend/style/colors'; export const Switch = styled.div` position: absolute; - top: 0.2rem; - right: 2.1rem; + top: 0.25rem; + right: 2.2rem; width: 1.25rem; height: 1.25rem; border-radius: 50%; diff --git a/frontend/hooks/useSetting.ts b/frontend/hooks/useSetting.ts index 02c639b..9d8d77f 100644 --- a/frontend/hooks/useSetting.ts +++ b/frontend/hooks/useSetting.ts @@ -2,9 +2,9 @@ import { useContext } from 'react'; import { useSelector } from 'react-redux'; import appContext from '../App/AppContext'; -import { isHidingUserAvatars, isShowingCompleted } from '../store/settings/selectors'; +import { isHidingUserAvatars, isShowingCompleted, isSoundEnabled } from '../store/settings/selectors'; -type Setting = 'showCompleted' | 'showAvatars'; +type Setting = 'showCompleted' | 'showAvatars' | 'sound'; const useSetting = (setting: Setting): boolean => { const context = useContext(appContext); @@ -18,6 +18,8 @@ const useSetting = (setting: Setting): boolean => { return !useSelector(isHidingUserAvatars); case 'showCompleted': return useSelector(isShowingCompleted); + case 'sound': + return useSelector(isSoundEnabled); } }; diff --git a/frontend/hooks/useSocket.ts b/frontend/hooks/useSocket.ts index 8d17fab..ba1d8c6 100644 --- a/frontend/hooks/useSocket.ts +++ b/frontend/hooks/useSocket.ts @@ -2,9 +2,12 @@ import { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { io } from 'socket.io-client'; +import useSetting from '/frontend/hooks/useSetting'; +import Sounds from '/frontend/sounds/Sounds'; import { addStatus, deleteStatus, patchStatus, setAllStatus } from '/frontend/store/status/actions'; import { socketEvent } from '/types/cimonitor'; +import Status from '/types/status'; type UseSocketOutput = { socketConnected: boolean; @@ -13,16 +16,34 @@ type UseSocketOutput = { const useSocket = (): UseSocketOutput => { const [socketConnected, setSocketConnected] = useState(false); const dispatch = useDispatch(); + const soundEnabled = useSetting('sound'); useEffect(() => { const socket = io(); socket.on(socketEvent.connect, () => setSocketConnected(true)); socket.on(socketEvent.disconnect, () => setSocketConnected(false)); - socket.on(socketEvent.allStatuses, (statuses) => dispatch(setAllStatus(statuses))); - socket.on(socketEvent.patchStatus, (status) => dispatch(patchStatus(status))); - socket.on(socketEvent.newStatus, (status) => dispatch(addStatus(status))); - socket.on(socketEvent.deleteStatus, (statusId) => dispatch(deleteStatus(statusId))); + socket.on(socketEvent.allStatuses, (statuses: Status[]) => dispatch(setAllStatus(statuses))); + socket.on(socketEvent.patchStatus, (status: Status) => dispatch(patchStatus(status))); + socket.on(socketEvent.newStatus, (status: Status) => dispatch(addStatus(status))); + socket.on(socketEvent.statusStateChange, (status: Status) => { + if (!soundEnabled) { + return; + } + + switch (status.state) { + case 'warning': + Sounds.playInfo(); + return; + case 'success': + Sounds.playSuccess(); + return; + case 'error': + Sounds.playError(); + return; + } + }); + socket.on(socketEvent.deleteStatus, (statusId: string) => dispatch(deleteStatus(statusId))); // Refresh all statuses once a day const requestStatusesInterval = setInterval(() => socket.emit(socketEvent.requestAllStatuses), 60000 * 60 * 24); @@ -31,7 +52,7 @@ const useSocket = (): UseSocketOutput => { socket.disconnect(); clearInterval(requestStatusesInterval); }; - }, []); + }, [soundEnabled]); return { socketConnected, diff --git a/frontend/sounds/README.md b/frontend/sounds/README.md new file mode 100644 index 0000000..4695925 --- /dev/null +++ b/frontend/sounds/README.md @@ -0,0 +1,6 @@ +# Sounds + +Included sounds are created by "FoolBoyMedia" and are released under the CC BY 4.0 DEED. +https://creativecommons.org/licenses/by/4.0/ + +The sounds can be found and downloaded from: https://freesound.org/people/FoolBoyMedia/packs/20086/ diff --git a/frontend/sounds/Sounds.ts b/frontend/sounds/Sounds.ts new file mode 100644 index 0000000..fd2f7c3 --- /dev/null +++ b/frontend/sounds/Sounds.ts @@ -0,0 +1,32 @@ +import ErrorSound from './error.mp3'; +import InfoSound from './info.mp3'; +import SuccessSound from './success.mp3'; + +class Sounds { + info: HTMLAudioElement; + error: HTMLAudioElement; + success: HTMLAudioElement; + + constructor() { + this.info = new Audio(InfoSound as string); + this.error = new Audio(ErrorSound as string); + this.success = new Audio(SuccessSound as string); + } + + playInfo() { + this.info.currentTime = 0; + this.info.play(); + } + + playSuccess() { + this.success.currentTime = 0; + this.success.play(); + } + + playError() { + this.error.currentTime = 0; + this.error.play(); + } +} + +export default new Sounds(); diff --git a/frontend/sounds/error.mp3 b/frontend/sounds/error.mp3 new file mode 100644 index 0000000..fa8e20a Binary files /dev/null and b/frontend/sounds/error.mp3 differ diff --git a/frontend/sounds/info.mp3 b/frontend/sounds/info.mp3 new file mode 100644 index 0000000..f3b2710 Binary files /dev/null and b/frontend/sounds/info.mp3 differ diff --git a/frontend/sounds/success.mp3 b/frontend/sounds/success.mp3 new file mode 100644 index 0000000..f753ca4 Binary files /dev/null and b/frontend/sounds/success.mp3 differ diff --git a/frontend/store/settings/actions.ts b/frontend/store/settings/actions.ts index bc42cf1..db4c109 100644 --- a/frontend/store/settings/actions.ts +++ b/frontend/store/settings/actions.ts @@ -4,6 +4,7 @@ import { ToggleSettingsPanelAction, ToggleShowCompletedAction, ToggleShowUserAvatarsAction, + ToggleSoundAction, } from './types'; export const toggleShowCompleted = (): ToggleShowCompletedAction => ({ @@ -14,6 +15,10 @@ export const toggleShowUserAvatars = (): ToggleShowUserAvatarsAction => ({ type: 'settings-show-user-avatars-toggle', }); +export const toggleSound = (): ToggleSoundAction => ({ + type: 'settings-sound-toggle', +}); + export const toggleSettingsPanel = (): ToggleSettingsPanelAction => ({ type: 'settings-panel-toggle', }); diff --git a/frontend/store/settings/reducer.ts b/frontend/store/settings/reducer.ts index 7ad9975..786f803 100644 --- a/frontend/store/settings/reducer.ts +++ b/frontend/store/settings/reducer.ts @@ -5,6 +5,7 @@ const defaultState: StateType = { showCompleted: false, sizeModifier: 1, showUserAvatars: true, + soundEnabled: false, }; const reducer = (state = defaultState, action: ActionTypes): StateType => { @@ -19,6 +20,11 @@ const reducer = (state = defaultState, action: ActionTypes): StateType => { ...state, open: false, }; + case 'settings-sound-toggle': + return { + ...state, + soundEnabled: !state.soundEnabled, + }; case 'settings-show-completed-toggle': return { ...state, diff --git a/frontend/store/settings/selectors.ts b/frontend/store/settings/selectors.ts index 27eea78..ab9c7a4 100644 --- a/frontend/store/settings/selectors.ts +++ b/frontend/store/settings/selectors.ts @@ -4,6 +4,8 @@ export const isSettingsPanelOpen = (state: RootState): boolean => state.setting. export const isShowingCompleted = (state: RootState): boolean => state.setting.showCompleted; +export const isSoundEnabled = (state: RootState): boolean => state.setting.soundEnabled; + export const isHidingUserAvatars = (state: RootState): boolean => !state.setting.showUserAvatars; export const getSizeModifier = (state: RootState): number => diff --git a/frontend/store/settings/types.ts b/frontend/store/settings/types.ts index a16ea9b..fab86fd 100644 --- a/frontend/store/settings/types.ts +++ b/frontend/store/settings/types.ts @@ -3,6 +3,7 @@ export type StateType = { showCompleted: boolean; sizeModifier: number; showUserAvatars: boolean; + soundEnabled: boolean; }; export type SetSizeModifierAction = { @@ -14,6 +15,10 @@ export type ToggleShowCompletedAction = { type: 'settings-show-completed-toggle'; }; +export type ToggleSoundAction = { + type: 'settings-sound-toggle'; +}; + export type ToggleSettingsPanelAction = { type: 'settings-panel-toggle'; }; @@ -31,4 +36,5 @@ export type ActionTypes = | CloseSettingsPanelAction | ToggleShowCompletedAction | SetSizeModifierAction - | ToggleShowUserAvatarsAction; + | ToggleShowUserAvatarsAction + | ToggleSoundAction; diff --git a/types/index.d.ts b/types/index.d.ts index f5bd015..71f3f35 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -9,3 +9,8 @@ declare module '*.png' { const content: string; export default content; } + +declare module '*.mp3' { + const content: string; + export default content; +}