Skip to content

Commit

Permalink
Add OBS integration and song request preview settings
Browse files Browse the repository at this point in the history
  • Loading branch information
DJDavid98 committed Oct 19, 2023
1 parent fd33842 commit 0c00527
Show file tree
Hide file tree
Showing 28 changed files with 787 additions and 200 deletions.
252 changes: 172 additions & 80 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"react-lottie-player": "^1.5.4",
"react-use-websocket": "^4.3.1",
"sass": "^1.57.1",
"swr": "^2.2.4",
"timers-browserify": "^2.0.12",
"tmi.js": "^1.8.5",
"ts-jest": "^29.1.1",
Expand Down
6 changes: 3 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mountAppComponent } from './js/utils/mount-app-component';
import { App } from './js/App';
import { SettingsManager } from './js/settings/SettingsManager';
import { SettingsProvider } from './js/settings/SettingsProvider';

/**
* Get the query parameters
Expand All @@ -9,9 +9,9 @@ import { SettingsManager } from './js/settings/SettingsManager';
const params = new URLSearchParams(window.location.search);

const WrappedApp: typeof App = (props) => (
<SettingsManager queryParams={params}>
<SettingsProvider queryParams={params}>
<App {...props} />
</SettingsManager>
</SettingsProvider>
);

mountAppComponent('app-root', WrappedApp, {});
41 changes: 41 additions & 0 deletions src/js/BeatSaverMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { FC, useMemo } from 'react';
import useSWR from 'swr';
import * as styles from '../scss/modules/BeatSaverMap.module.scss';
import { SongInfo } from './beat-saber/SongInfo';
import { validateBeatSaverMapData } from './validators/validate-beat-saver-map-data';
import classNames from 'classnames';
import { mapDifficulty } from './utils/mappers';

export interface BeatSaverMapProps {
mapId: string;
text?: string;
inChat?: boolean;
}

export const BeatSaverMap: FC<BeatSaverMapProps> = ({ mapId, inChat }) => {
const {
data,
isLoading
} = useSWR(() => mapId ? `bsr-map-${mapId}` : null, () => fetch(`https://api.beatsaver.com/maps/id/${mapId}`).then(r => r.json()).then(data => validateBeatSaverMapData(data)), {
revalidateOnFocus: false,
refreshWhenHidden: false,
refreshWhenOffline: false,
});
const versions = data?.versions;
const publishedVersion = useMemo(() => versions?.slice().sort((a,
b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).find(v => v.state === 'Published'), [versions]);
return <div className={classNames(styles['beat-saver-map'], { [styles['in-chat']]: inChat })}>
<div className={styles['beat-saver-song-info']}>
<SongInfo
name={data?.metadata?.songName ?? (isLoading ? '' : 'Could not retrieve song information')}
author={isLoading ? 'Loading map data…' : data?.metadata?.songAuthorName}
duration={data?.metadata?.duration}
mapper={data?.metadata?.levelAuthorName}
subName={data?.metadata?.songSubName}
url={publishedVersion?.coverURL}
bsr={data?.id}
difficulty={publishedVersion?.diffs?.filter(diff => diff.characteristic === 'Standard').map(diff => mapDifficulty(diff.difficulty)).join(', ')}
/>
</div>
</div>;
};
1 change: 1 addition & 0 deletions src/js/Loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type LoaderName =
| 'ble'
| 'pulsoid'
| 'connection'
| 'beat-saver-map'

type LoaderClass = `${LoaderName}-loading`;

Expand Down
2 changes: 2 additions & 0 deletions src/js/beat-saber/SongAuthor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface SongAuthorProps {
}

export const SongAuthor: FunctionComponent<SongAuthorProps> = ({ author, mapper }) => {
if (!author && !mapper) return null;

return (
<SongInfoLine className="song-author">
{author}
Expand Down
26 changes: 20 additions & 6 deletions src/js/beat-saber/SongDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,28 @@ export const SongDetails: FunctionComponent<SongDetails> = ({
const nf = useMemo(() => new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }), []);
const df = useDurationFormatTimer();

const items = [];
if (difficulty) {
items.push(<span className="difficulty">{mapDifficulty(difficulty)}</span>);
}
if (duration) {
items.push(<span className="duration">{df.format(duration)}</span>);
}
if (star) {
items.push(<span className="star">{nf.format(star)}</span>);
}
if (pp) {
items.push(<span className="pp">{nf.format(pp)}pp</span>);
}
if (bsr) {
items.push(<span className="bsr">!bsr {mapDifficulty(bsr)}</span>);
}

if (items.length === 0) return null;

return <SongInfoLine className="song-details">
<span className="song-detail-items">
{difficulty &&
<span className="difficulty">{mapDifficulty(difficulty)}</span>}
{!!duration && <span className="duration">{df.format(duration)}</span>}
{!!star && <span className="star">{nf.format(star)}</span>}
{!!pp && <span className="pp">{nf.format(pp)}pp</span>}
{bsr && <span className="bsr">!bsr {mapDifficulty(bsr)}</span>}
{items}
</span>
</SongInfoLine>;
};
14 changes: 11 additions & 3 deletions src/js/chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
ChatSystemMessage,
ChatUserMessage,
DisplayableMessage,
getChatWebsocketMessageTimestamp, removeEmotes,
getChatWebsocketMessageTimestamp, isSongRequest, removeEmotes,
SystemMessageType, tokenizeMessage, ttsNameSubstitutions
} from '../utils/chat-messages';
import { ChatMessage } from './ChatMessage';
Expand All @@ -21,6 +21,7 @@ export const Chat: FC = () => {
settings: {
[SettingName.ELEVEN_LABS_TOKEN]: elevenLabsToken,
[SettingName.TTS_ENABLED]: ttsEnabled,
[SettingName.CHAT_SONG_PREVIEWS]: chatSongPreviews,
}
} = useSettings();
const [messages, setMessages] = useState<Array<DisplayableMessage>>(() => []);
Expand Down Expand Up @@ -81,7 +82,14 @@ export const Chat: FC = () => {
tts.readText(data.message);
},
chat(message) {
const { tokens, emoteOnly } = tokenizeMessage(message.message, message.tags.emotes);
if (!chatSongPreviews && isSongRequest(message.message)) {
return;
}

const {
tokens,
emoteOnly
} = tokenizeMessage(message.message, message.tags.emotes);
const data: ChatUserMessage = {
id: message.tags.id || window.crypto.randomUUID(),
name: message.name,
Expand Down Expand Up @@ -168,7 +176,7 @@ export const Chat: FC = () => {
socket.off(kex, listeners[kex]);
}
};
}, [addMessage, df, socket, tts]);
}, [addMessage, chatSongPreviews, df, socket, tts]);

return <Fragment>
{messages.map(message => <ChatMessage key={message.id} message={message} />)}
Expand Down
6 changes: 5 additions & 1 deletion src/js/chat/ChatMessageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FC, memo } from 'react';
import { ChatEmote } from './ChatEmote';
import { ChatUserMessage } from '../utils/chat-messages';
import { format } from 'date-fns';
import { BeatSaverMap } from '../BeatSaverMap';

const ChatMessageBodyComponent: FC<Pick<ChatUserMessage, 'timestamp' | 'tokens' | 'emoteOnly' | 'messageColor'>> = ({
timestamp,
Expand All @@ -14,8 +15,11 @@ const ChatMessageBodyComponent: FC<Pick<ChatUserMessage, 'timestamp' | 'tokens'
<span className="chat-message-send-timestamp">{formattedTs}</span>
{
tokens.map((token, index) => {
if (typeof token !== 'string')
if (typeof token !== 'string') {
if ('mapId' in token)
return <BeatSaverMap key={index} mapId={token.mapId} inChat />;
return <ChatEmote key={index} {...token} large={emoteOnly} />;
}

return <span key={index}>{token}</span>;
})
Expand Down
5 changes: 4 additions & 1 deletion src/js/chat/SystemMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { ChatMessageBody } from './ChatMessageBody';
import { accentColorCssVariable, ChatSystemMessage, tokenizeMessage } from '../utils/chat-messages';

export const SystemMessage: FC<ChatSystemMessage> = ({ timestamp, message }) => {
const { tokens, emoteOnly } = useMemo(() => tokenizeMessage(message, undefined), [message]);
const {
tokens,
emoteOnly
} = useMemo(() => tokenizeMessage(message, undefined), [message]);
return <ChatMessageBody
timestamp={timestamp}
tokens={tokens}
Expand Down
60 changes: 16 additions & 44 deletions src/js/hooks/use-obs-control.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,19 @@
import { useCallback, useEffect, useState } from 'react';
import { isInBrowserSource } from '../utils/is-in-browser-source';
import { useCallback, useEffect } from 'react';
import { ReadyState } from 'react-use-websocket';

const brbSceneName = 'BRB';
const mainSceneName = 'Main';
const farewellSceneName = 'Farewell';
const outroSongBsr = '39';
import { useObs } from './use-obs';
import { useSettings } from '../contexts/settings-context';
import { SettingName } from '../model/settings';

export const useObsControl = (mapDataReadyState: ReadyState, bsrKey: unknown) => {
const [controlLevel, setControlLevel] = useState<OBSControlLevel>(0);
const [streaming, setStreaming] = useState<OBSStatus['streaming']>(false);
const [currentSceneName, setCurrentSceneName] = useState<OBSSceneInfo['name']>('');

useEffect(() => {
if (!isInBrowserSource()) return;

window.obsstudio.getControlLevel((level) => {
setControlLevel(level);
});
window.obsstudio.getStatus((status) => {
setStreaming(status !== null && status.streaming);
});
const listeners = {
obsSceneChanged(event: CustomEvent<OBSSceneInfo>) {
setCurrentSceneName(event.detail.name);
},
obsStreamingStarting() {
setStreaming(true);
},
obsStreamingStopping() {
setStreaming(false);
},
} satisfies Partial<{ [k in keyof OBSStudioEventMap]: (event: OBSStudioEventMap[k]) => void }>;

Object.entries(listeners).forEach(([event, handler]) => {
window.addEventListener(event as never, handler as never);
});
return () => {
Object.entries(listeners).forEach(([event, handler]) => {
window.removeEventListener(event as never, handler as never);
});
};
}, []);
const {
settings: {
[SettingName.OBS_BRB_SCENE]: brbSceneName,
[SettingName.OBS_PRIMARY_SCENE]: primarySceneName,
[SettingName.OBS_FAREWELL_SCENE]: farewellSceneName,
[SettingName.OUTRO_SONG_BSR]: outroSongBsr,
}
} = useSettings();
const { controlLevel, streaming, currentSceneName } = useObs();

const getTargetSceneName = useCallback(() => {
if (bsrKey === outroSongBsr) {
Expand All @@ -50,15 +22,15 @@ export const useObsControl = (mapDataReadyState: ReadyState, bsrKey: unknown) =>
}

// Show BRB scene while overlay is disconnected during streaming
return mapDataReadyState !== ReadyState.OPEN ? brbSceneName : mainSceneName;
}, [bsrKey, mapDataReadyState]);
return mapDataReadyState !== ReadyState.OPEN ? brbSceneName : primarySceneName;
}, [brbSceneName, bsrKey, farewellSceneName, primarySceneName, mapDataReadyState, outroSongBsr]);

useEffect(() => {
// At sufficient control level, switch to the pre-defined scene
if (controlLevel < 4 || !streaming) return;

const targetSceneName = getTargetSceneName();
if (currentSceneName !== targetSceneName) {
if (targetSceneName && currentSceneName !== targetSceneName) {
window.obsstudio.setCurrentScene(targetSceneName);
}
}, [controlLevel, currentSceneName, getTargetSceneName, streaming]);
Expand Down
57 changes: 57 additions & 0 deletions src/js/hooks/use-obs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { isInBrowserSource } from '../utils/is-in-browser-source';

export const useObs = () => {
const inBrowserSource = useMemo(() => isInBrowserSource(), []);
const [controlLevel, setControlLevel] = useState<OBSControlLevel>(0);
const [streaming, setStreaming] = useState<OBSStatus['streaming']>(false);
const [currentSceneName, setCurrentSceneName] = useState<OBSSceneInfo['name']>('');
const [allSceneNames, setAllSceneNames] = useState<OBSSceneInfo['name'][]>([]);

const updateScenes = useCallback(() => {
window.obsstudio.getScenes((scenes) => {
setAllSceneNames(scenes);
});
}, []);

useEffect(() => {
if (!inBrowserSource) return;

window.obsstudio.getControlLevel((level) => {
setControlLevel(level);
});
window.obsstudio.getStatus((status) => {
setStreaming(status !== null && status.streaming);
});
updateScenes();
const listeners = {
obsSceneChanged(event: CustomEvent<OBSSceneInfo>) {
setCurrentSceneName(event.detail.name);
updateScenes();
},
obsStreamingStarting() {
setStreaming(true);
},
obsStreamingStopping() {
setStreaming(false);
},
} satisfies Partial<{ [k in keyof OBSStudioEventMap]: (event: OBSStudioEventMap[k]) => void }>;

Object.entries(listeners).forEach(([event, handler]) => {
window.addEventListener(event as never, handler as never);
});
return () => {
Object.entries(listeners).forEach(([event, handler]) => {
window.removeEventListener(event as never, handler as never);
});
};
}, [inBrowserSource, updateScenes]);

return {
inBrowserSource,
controlLevel,
streaming,
currentSceneName,
allSceneNames
};
};
25 changes: 25 additions & 0 deletions src/js/model/beat-saver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface BeatSaverMapMetadata {
duration: number;
songName: string;
songSubName: string;
songAuthorName: string;
levelAuthorName: string;
}

export interface BeatSaverMapVersion {
state: string;
diffs: BeatSaverMapVersionDifficulty[];
createdAt: string;
coverURL?: string;
}

export interface BeatSaverMapVersionDifficulty {
characteristic: string;
difficulty: string;
}

export interface BeatSaverMapData {
id: string;
metadata?: BeatSaverMapMetadata;
versions?: BeatSaverMapVersion[];
}
11 changes: 11 additions & 0 deletions src/js/model/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export enum SettingName {
ELEVEN_LABS_TOKEN = 'elevenLabsToken',
TTS_ENABLED = 'ttsEnabled',
BEAT_SABER_DATA_SOURCE = 'beatSaberDataSource',
OBS_PRIMARY_SCENE = 'obsPrimaryScene',
OBS_BRB_SCENE = 'obsBrbScene',
OBS_FAREWELL_SCENE = 'obsFarewellScene',
OUTRO_SONG_BSR = 'outro-song-bsr',
CHAT_SONG_PREVIEWS = 'chat-song-previews',
}

export interface SettingTypes {
Expand All @@ -23,6 +28,11 @@ export interface SettingTypes {
[SettingName.BEAT_SABER_DATA_SOURCE]: BeatSaberDataSource;
[SettingName.ELEVEN_LABS_TOKEN]: string;
[SettingName.TTS_ENABLED]: boolean;
[SettingName.OBS_PRIMARY_SCENE]: string;
[SettingName.OBS_BRB_SCENE]: string;
[SettingName.OBS_FAREWELL_SCENE]: string;
[SettingName.OUTRO_SONG_BSR]: string;
[SettingName.CHAT_SONG_PREVIEWS]: boolean;
}

export type SettingsObject = {
Expand All @@ -37,6 +47,7 @@ export enum SettingsPage {
CHAT_OVERLAY = 'chat-overlay',
BOUNCY = 'bouncy',
IMPORT_EXPORT = 'import-export',
OBS_INTEGRATION = 'obs-integration',
CREDITS = 'credits',
}

Expand Down
Loading

0 comments on commit 0c00527

Please sign in to comment.