diff --git a/web/components/chat/ChatContainer/ChatContainer.tsx b/web/components/chat/ChatContainer/ChatContainer.tsx
index 4c10a5754..e87b2a6b8 100644
--- a/web/components/chat/ChatContainer/ChatContainer.tsx
+++ b/web/components/chat/ChatContainer/ChatContainer.tsx
@@ -2,20 +2,19 @@ import { Spin } from 'antd';
import { Virtuoso } from 'react-virtuoso';
import { useRef } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
-import { ChatMessage } from '../../../interfaces/chat-message.model';
-import { ChatState } from '../../../interfaces/application-state';
+
import { MessageType } from '../../../interfaces/socket-events';
import s from './ChatContainer.module.scss';
+import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatUserMessage } from '..';
interface Props {
messages: ChatMessage[];
- state: ChatState;
+ loading: boolean;
}
export default function ChatContainer(props: Props) {
- const { messages, state } = props;
- const loading = state === ChatState.Loading;
+ const { messages, loading } = props;
const chatContainerRef = useRef(null);
const spinIcon = ;
diff --git a/web/components/chat/ChatModeratorNotification.tsx b/web/components/chat/ChatModeratorNotification.tsx
deleted file mode 100644
index f6e39f4fe..000000000
--- a/web/components/chat/ChatModeratorNotification.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unused-vars */
-interface Props {}
-
-export default function ChatModerationNotification(props: Props) {
- return
You are now a moderator notification component goes here
;
-}
diff --git a/web/components/common/UserDropdown/UserDropdown.tsx b/web/components/common/UserDropdown/UserDropdown.tsx
index ca92ebe6a..bd7ac8955 100644
--- a/web/components/common/UserDropdown/UserDropdown.tsx
+++ b/web/components/common/UserDropdown/UserDropdown.tsx
@@ -9,27 +9,30 @@ import {
import { useRecoilState, useRecoilValue } from 'recoil';
import { useState } from 'react';
import Modal from '../../ui/Modal/Modal';
-import { chatVisibilityAtom, chatDisplayNameAtom } from '../../stores/ClientConfigStore';
-import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state';
+import {
+ chatVisibleToggleAtom,
+ chatDisplayNameAtom,
+ appStateAtom,
+} from '../../stores/ClientConfigStore';
import s from './UserDropdown.module.scss';
import NameChangeModal from '../../modals/NameChangeModal';
+import { AppStateOptions } from '../../stores/application-state';
interface Props {
username?: string;
- chatState: ChatState;
}
-export default function UserDropdown({ username: defaultUsername, chatState }: Props) {
- const [chatVisibility, setChatVisibility] =
- useRecoilState(chatVisibilityAtom);
+export default function UserDropdown({ username: defaultUsername }: Props) {
const username = defaultUsername || useRecoilValue(chatDisplayNameAtom);
const [showNameChangeModal, setShowNameChangeModal] = useState(false);
+ const [chatToggleVisible, setChatToggleVisible] = useRecoilState(chatVisibleToggleAtom);
+ const appState = useRecoilValue(appStateAtom);
const toggleChatVisibility = () => {
- if (chatVisibility === ChatVisibilityState.Hidden) {
- setChatVisibility(ChatVisibilityState.Visible);
+ if (!chatToggleVisible) {
+ setChatToggleVisible(true);
} else {
- setChatVisibility(ChatVisibilityState.Hidden);
+ setChatToggleVisible(false);
}
};
@@ -45,7 +48,7 @@ export default function UserDropdown({ username: defaultUsername, chatState }: P
}>
Authenticate
- {chatState === ChatState.Available && (
+ {appState.chatAvailable && (
} onClick={() => toggleChatVisibility()}>
Toggle chat
diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx
index 9073d8d04..19dd0338f 100644
--- a/web/components/stores/ClientConfigStore.tsx
+++ b/web/components/stores/ClientConfigStore.tsx
@@ -1,31 +1,32 @@
-/* eslint-disable no-case-declarations */
import { useEffect } from 'react';
-import { atom, useRecoilState, useSetRecoilState } from 'recoil';
+import { atom, selector, useRecoilState, useSetRecoilState } from 'recoil';
+import { useMachine } from '@xstate/react';
import { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model';
import ClientConfigService from '../../services/client-config-service';
import ChatService from '../../services/chat-service';
import WebsocketService from '../../services/websocket-service';
import { ChatMessage } from '../../interfaces/chat-message.model';
import { ServerStatus, makeEmptyServerStatus } from '../../interfaces/server-status.model';
-
-import {
- AppState,
- ChatState,
- VideoState,
- ChatVisibilityState,
- getChatState,
- getChatVisibilityState,
-} from '../../interfaces/application-state';
+import appStateModel, {
+ AppStateEvent,
+ AppStateOptions,
+ makeEmptyAppState,
+} from './application-state';
+import { setLocalStorage, getLocalStorage } from '../../utils/helpers';
import {
ConnectedClientInfoEvent,
MessageType,
ChatEvent,
SocketEvent,
} from '../../interfaces/socket-events';
-import handleConnectedClientInfoMessage from './eventhandlers/connectedclientinfo';
+
import handleChatMessage from './eventhandlers/handleChatMessage';
+import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler';
import ServerStatusService from '../../services/status-service';
+const SERVER_STATUS_POLL_DURATION = 5000;
+const ACCESS_TOKEN_KEY = 'accessToken';
+
// Server status is what gets updated such as viewer count, durations,
// stream title, online/offline state, etc.
export const serverStatusState = atom({
@@ -39,26 +40,6 @@ export const clientConfigStateAtom = atom({
default: makeEmptyClientConfig(),
});
-export const appStateAtom = atom({
- key: 'appStateAtom',
- default: AppState.Loading,
-});
-
-export const chatStateAtom = atom({
- key: 'chatStateAtom',
- default: ChatState.Offline,
-});
-
-export const videoStateAtom = atom({
- key: 'videoStateAtom',
- default: VideoState.Unavailable,
-});
-
-export const chatVisibilityAtom = atom({
- key: 'chatVisibility',
- default: ChatVisibilityState.Visible,
-});
-
export const chatDisplayNameAtom = atom({
key: 'chatDisplayName',
default: null,
@@ -79,23 +60,79 @@ export const websocketServiceAtom = atom({
default: null,
});
+export const appStateAtom = atom({
+ key: 'appState',
+ default: makeEmptyAppState(),
+});
+
+export const chatVisibleToggleAtom = atom({
+ key: 'chatVisibilityToggleAtom',
+ default: true,
+});
+
+export const isVideoPlayingAtom = atom({
+ key: 'isVideoPlayingAtom',
+ default: false,
+});
+
+// Chat is visible if the user wishes it to be visible AND the required
+// chat state is set.
+export const isChatVisibleSelector = selector({
+ key: 'isChatVisibleSelector',
+ get: ({ get }) => {
+ const state: AppStateOptions = get(appStateAtom);
+ const userVisibleToggle: boolean = get(chatVisibleToggleAtom);
+ const accessToken: String = get(accessTokenAtom);
+ return accessToken && state.chatAvailable && userVisibleToggle;
+ },
+});
+
+// We display in an "online/live" state as long as video is actively playing.
+// Even during the time where technically the server has said it's no longer
+// live, however the last few seconds of video playback is still taking place.
+export const isOnlineSelector = selector({
+ key: 'isOnlineSelector',
+ get: ({ get }) => {
+ const state: AppStateOptions = get(appStateAtom);
+ const isVideoPlaying: boolean = get(isVideoPlayingAtom);
+ return state.videoAvailable || isVideoPlaying;
+ },
+});
+
+// Take a nested object of state metadata and merge it into
+// a single flattened node.
+function mergeMeta(meta) {
+ return Object.keys(meta).reduce((acc, key) => {
+ const value = meta[key];
+ Object.assign(acc, value);
+
+ return acc;
+ }, {});
+}
+
export function ClientConfigStore() {
+ const [appState, appStateSend, appStateService] = useMachine(appStateModel);
+
+ const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom);
const setClientConfig = useSetRecoilState(clientConfigStateAtom);
const setServerStatus = useSetRecoilState(serverStatusState);
- const setChatVisibility = useSetRecoilState(chatVisibilityAtom);
- const setChatState = useSetRecoilState(chatStateAtom);
const [chatMessages, setChatMessages] = useRecoilState(chatMessagesAtom);
- const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom);
- const [appState, setAppState] = useRecoilState(appStateAtom);
const [accessToken, setAccessToken] = useRecoilState(accessTokenAtom);
- const setWebsocketService = useSetRecoilState(websocketServiceAtom);
+ const setAppState = useSetRecoilState(appStateAtom);
+ const setWebsocketService = useSetRecoilState(websocketServiceAtom);
let ws: WebsocketService;
+ const sendEvent = (event: string) => {
+ // console.log('---- sending event:', event);
+ appStateSend({ type: event });
+ };
+
const updateClientConfig = async () => {
try {
const config = await ClientConfigService.getConfig();
setClientConfig(config);
+ sendEvent('LOADED');
} catch (error) {
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
}
@@ -105,32 +142,42 @@ export function ClientConfigStore() {
try {
const status = await ServerStatusService.getStatus();
setServerStatus(status);
+
if (status.online) {
- setAppState(AppState.Online);
- } else {
- setAppState(AppState.Offline);
+ sendEvent(AppStateEvent.Online);
+ } else if (!status.online) {
+ sendEvent(AppStateEvent.Offline);
}
- return status;
} catch (error) {
+ sendEvent(AppStateEvent.Fail);
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
- return null;
}
+ return null;
};
const handleUserRegistration = async (optionalDisplayName?: string) => {
+ const savedAccessToken = getLocalStorage(ACCESS_TOKEN_KEY);
+ if (savedAccessToken) {
+ setAccessToken(savedAccessToken);
+ return;
+ }
+
try {
- setAppState(AppState.Registering);
+ sendEvent(AppStateEvent.NeedsRegister);
const response = await ChatService.registerUser(optionalDisplayName);
console.log(`ChatService -> registerUser() response: \n${response}`);
const { accessToken: newAccessToken, displayName: newDisplayName } = response;
if (!newAccessToken) {
return;
}
+
console.log('setting access token', newAccessToken);
setAccessToken(newAccessToken);
- // setLocalStorage('accessToken', newAccessToken);
+ setLocalStorage(ACCESS_TOKEN_KEY, newAccessToken);
setChatDisplayName(newDisplayName);
+ // sendEvent(AppStateEvent.Registered);
} catch (e) {
+ sendEvent(AppStateEvent.Fail);
console.error(`ChatService -> registerUser() ERROR: \n${e}`);
}
};
@@ -138,7 +185,7 @@ export function ClientConfigStore() {
const handleMessage = (message: SocketEvent) => {
switch (message.type) {
case MessageType.CONNECTED_USER_INFO:
- handleConnectedClientInfoMessage(message as ConnectedClientInfoEvent);
+ handleConnectedClientInfoMessage(message as ConnectedClientInfoEvent, setChatDisplayName);
break;
case MessageType.CHAT:
handleChatMessage(message as ChatEvent, chatMessages, setChatMessages);
@@ -159,11 +206,12 @@ export function ClientConfigStore() {
};
const startChat = async () => {
- setChatState(ChatState.Loading);
+ sendEvent(AppStateEvent.Loading);
try {
ws = new WebsocketService(accessToken, '/ws');
ws.handleMessage = handleMessage;
setWebsocketService(ws);
+ sendEvent(AppStateEvent.Loaded);
} catch (error) {
console.error(`ChatService -> startChat() ERROR: \n${error}`);
}
@@ -172,14 +220,11 @@ export function ClientConfigStore() {
useEffect(() => {
updateClientConfig();
handleUserRegistration();
- }, []);
-
- useEffect(() => {
+ updateServerStatus();
setInterval(() => {
updateServerStatus();
- }, 5000);
- updateServerStatus();
- }, []);
+ }, SERVER_STATUS_POLL_DURATION);
+ }, [appState]);
useEffect(() => {
if (!accessToken) {
@@ -190,21 +235,18 @@ export function ClientConfigStore() {
startChat();
}, [accessToken]);
- useEffect(() => {
- const updatedChatState = getChatState(appState);
- console.log('updatedChatState', updatedChatState);
- setChatState(updatedChatState);
- const updatedChatVisibility = getChatVisibilityState(appState);
- console.log(
- 'app state: ',
- AppState[appState],
- 'chat state:',
- ChatState[updatedChatState],
- 'chat visibility:',
- ChatVisibilityState[updatedChatVisibility],
- );
- setChatVisibility(updatedChatVisibility);
- }, [appState]);
+ appStateService.onTransition(state => {
+ if (!state.changed) {
+ return;
+ }
+
+ const metadata = mergeMeta(state.meta) as AppStateOptions;
+
+ console.log('--- APP STATE: ', state.value);
+ console.log('--- APP META: ', metadata);
+
+ setAppState(metadata);
+ });
return null;
}
diff --git a/web/components/stores/application-state.ts b/web/components/stores/application-state.ts
new file mode 100644
index 000000000..e66c56f77
--- /dev/null
+++ b/web/components/stores/application-state.ts
@@ -0,0 +1,134 @@
+/*
+This is a finite state machine model that is used by xstate. https://xstate.js.org/
+You send events to it and it changes state based on the pre-determined
+modeling.
+This allows for a clean and reliable way to model the current state of the
+web application, and a single place to determine the flow of states.
+
+You can paste this code into https://stately.ai/viz to see a visual state
+map or install the VS Code plugin:
+https://marketplace.visualstudio.com/items?itemName=statelyai.stately-vscode
+*/
+
+import { createMachine } from 'xstate';
+
+export interface AppStateOptions {
+ chatAvailable: boolean;
+ chatLoading?: boolean;
+ videoAvailable: boolean;
+ appLoading?: boolean;
+}
+
+export function makeEmptyAppState(): AppStateOptions {
+ return {
+ chatAvailable: false,
+ chatLoading: true,
+ videoAvailable: false,
+ appLoading: true,
+ };
+}
+
+const OFFLINE_STATE: AppStateOptions = {
+ chatAvailable: false,
+ chatLoading: false,
+ videoAvailable: false,
+ appLoading: false,
+};
+
+const ONLINE_STATE: AppStateOptions = {
+ chatAvailable: true,
+ chatLoading: false,
+ videoAvailable: true,
+ appLoading: false,
+};
+
+const LOADING_STATE: AppStateOptions = {
+ chatAvailable: false,
+ chatLoading: false,
+ videoAvailable: false,
+ appLoading: true,
+};
+
+const GOODBYE_STATE: AppStateOptions = {
+ chatAvailable: true,
+ chatLoading: false,
+ videoAvailable: false,
+ appLoading: false,
+};
+
+export enum AppStateEvent {
+ Loading = 'LOADING',
+ Loaded = 'LOADED',
+ Online = 'ONLINE',
+ Offline = 'OFFLINE', // Have not pulled configuration data from the server.
+ NeedsRegister = 'NEEDS_REGISTER',
+ Fail = 'FAIL',
+}
+
+const appStateModel =
+ /** @xstate-layout N4IgpgJg5mDOIC5QEMAOqDKAXZWwDoAbAe2QgEsA7KAYgCUBRAcQEkMAVBxgEUVFWKxyWcsUp8QAD0QBGAGwz8ABgCscpUoDsAZgAcKgEwrtATgA0IAJ6zNS-CZMLtcuQBY9Jg5t0BfHxbRMHDwiUgpqGgA5BgZuDAB9RlYOLgkBIRExCWkEGRVFVXUtPUNjcytEAxNXfB0DbSNNORMG119-EEDsXAISMipaABkAeQBBbli0wWFRcSQpWQVlNQ0dfSNTC2tc+vwZVwMZWxNbA5kDAz8A9G6QvvDaADFRlkGpjNns2VcCleL1spbRDaA74FS6ORVfYHTSObRXTo3YIEABOYCg5FgeBRA3ozDYnB47xmWXmOQOKj22hUnl02iajjk2iBCCqdgO2n2VRcbQhCK6yPwaIxWLAOIiz1exMyc1A5KMVJpBjpDJczIqCG0enwXk0MiUENMjiUBjk-KRPSFYDIlnwYkIVDANGGj0egxY0WlnzJiF0TXwulU9LqWhMMl0LIM7ipmguIIObU85qClrRNrtADMMw7KE7hpF3Z75ukSbKFgg5Pp7PSQdTXBp9uVtlHtDG464E7okx0BanrRBbVBiMQIAAjSxOyRYy3IDPYgAU2g0y4AlDReyE0wP8EOR+OwF7SXLff7A8ZNCHYeGWedW3qlDIWkz61rLgjKCO4BIN70wgND2W8o3pyyjKrCdZ6q4sYqMmtyouimLYv+xbTDKXyakuyiuHI+QmCoJquCo2FyCyS52PURzqI+mgqIYpqwYKW62vajoAehsJyFS+paPhfoRhqUa6PgTIuLoRznComiEWaPYWpu-bMVmOYHihHxHuWRQ6pCJz0sqGgNMBBjCfohiEUoIJ0kyDF9umu5jhObE+rkqh2BCLS8XhYa6K4wGUuGtEviYZ5qNZ8k2o5x4IJJnGmlUOixoG5kGCy+G1JyXgHGJJhKByrihQQsBigAbmKjzIOQhAAK5ohF5YXJx+q2MYbieFB4KkW0yj6NBfr6vU3n5fglWFSiGblVVNWqaW6H5HY4bNFJbhKI4HV3k0rgnNlS6xm+1wpngtU5NeGoALQXDqbVBct+g5Qofh+EAA */
+ createMachine({
+ id: 'appState',
+ initial: 'loading',
+ states: {
+ loading: {
+ meta: {
+ ...LOADING_STATE,
+ },
+ on: {
+ NEEDS_REGISTER: {
+ target: 'loading',
+ },
+ LOADED: {
+ target: 'ready',
+ },
+ FAIL: {
+ target: 'serverFailure',
+ },
+ },
+ },
+ ready: {
+ initial: 'offline',
+ states: {
+ online: {
+ meta: {
+ ...ONLINE_STATE,
+ },
+ on: {
+ OFFLINE: {
+ target: 'goodbye',
+ },
+ },
+ },
+ offline: {
+ meta: {
+ ...OFFLINE_STATE,
+ },
+ on: {
+ ONLINE: {
+ target: 'online',
+ },
+ },
+ },
+ goodbye: {
+ meta: {
+ ...GOODBYE_STATE,
+ },
+ after: {
+ '300000': {
+ target: 'offline',
+ },
+ },
+ },
+ },
+ },
+ serverFailure: {
+ type: 'final',
+ },
+ userfailure: {
+ type: 'final',
+ },
+ },
+ });
+
+export default appStateModel;
diff --git a/web/components/stores/eventhandlers/connected-client-info-handler.ts b/web/components/stores/eventhandlers/connected-client-info-handler.ts
new file mode 100644
index 000000000..221c68ae9
--- /dev/null
+++ b/web/components/stores/eventhandlers/connected-client-info-handler.ts
@@ -0,0 +1,11 @@
+import { ConnectedClientInfoEvent } from '../../../interfaces/socket-events';
+
+export default function handleConnectedClientInfoMessage(
+ message: ConnectedClientInfoEvent,
+ setChatDisplayName: (string) => void,
+) {
+ console.log('connected client', message);
+ const { user } = message;
+ const { displayName } = user;
+ setChatDisplayName(displayName);
+}
diff --git a/web/components/stores/eventhandlers/connectedclientinfo.ts b/web/components/stores/eventhandlers/connectedclientinfo.ts
deleted file mode 100644
index e5081d4b2..000000000
--- a/web/components/stores/eventhandlers/connectedclientinfo.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { ConnectedClientInfoEvent } from '../../../interfaces/socket-events';
-
-export default function handleConnectedClientInfoMessage(message: ConnectedClientInfoEvent) {
- console.log('connected client', message);
-}
diff --git a/web/components/ui/Content/Content.module.scss b/web/components/ui/Content/Content.module.scss
index 11538cb51..a557dd212 100644
--- a/web/components/ui/Content/Content.module.scss
+++ b/web/components/ui/Content/Content.module.scss
@@ -54,6 +54,13 @@
}
}
+.loadingSpinner {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ z-index: 999999;
+}
+
@media (min-width: 768px) {
.mobileChat {
display: none;
diff --git a/web/components/ui/Content/Content.tsx b/web/components/ui/Content/Content.tsx
index f4797513a..ff78c7a20 100644
--- a/web/components/ui/Content/Content.tsx
+++ b/web/components/ui/Content/Content.tsx
@@ -1,12 +1,13 @@
import { useRecoilValue } from 'recoil';
-import { Layout, Button, Tabs } from 'antd';
+import { Layout, Button, Tabs, Spin } from 'antd';
import { NotificationFilled, HeartFilled } from '@ant-design/icons';
import {
- chatVisibilityAtom,
clientConfigStateAtom,
chatMessagesAtom,
- chatStateAtom,
+ isChatVisibleSelector,
serverStatusState,
+ appStateAtom,
+ isOnlineSelector,
} from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model';
import CustomPageContent from '../../CustomPageContent';
@@ -17,7 +18,6 @@ import Sidebar from '../Sidebar';
import Footer from '../Footer';
import ChatContainer from '../../chat/ChatContainer';
import { ChatMessage } from '../../../interfaces/chat-message.model';
-import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state';
import ChatTextField from '../../chat/ChatTextField/ChatTextField';
import ActionButtonRow from '../../action-buttons/ActionButtonRow';
import ActionButton from '../../action-buttons/ActionButton';
@@ -28,27 +28,27 @@ import SocialLinks from '../SocialLinks/SocialLinks';
import NotifyReminderPopup from '../NotifyReminderPopup/NotifyReminderPopup';
import ServerLogo from '../Logo/Logo';
import CategoryIcon from '../CategoryIcon/CategoryIcon';
+import OfflineBanner from '../OfflineBanner/OfflineBanner';
+import { AppStateOptions } from '../../stores/application-state';
const { TabPane } = Tabs;
const { Content } = Layout;
export default function ContentComponent() {
+ const appState = useRecoilValue(appStateAtom);
const status = useRecoilValue(serverStatusState);
const clientConfig = useRecoilValue(clientConfigStateAtom);
- const chatVisibility = useRecoilValue(chatVisibilityAtom);
+ const isChatVisible = useRecoilValue(isChatVisibleSelector);
const messages = useRecoilValue(chatMessagesAtom);
- const chatState = useRecoilValue(chatStateAtom);
+ const online = useRecoilValue(isOnlineSelector);
const { extraPageContent, version, socialHandles, name, title, tags } = clientConfig;
- const { online, viewerCount, lastConnectTime, lastDisconnectTime } = status;
+ const { viewerCount, lastConnectTime, lastDisconnectTime } = status;
const followers: Follower[] = [];
const total = 0;
- const chatVisible =
- chatState === ChatState.Available && chatVisibility === ChatVisibilityState.Visible;
-
// This is example content. It should be removed.
const externalActions = [
{
@@ -67,8 +67,12 @@ export default function ContentComponent() {
return (
+
+
-
+ {online &&
}
+ {!online &&
}
+
- {chatVisibility && (
+ {isChatVisible && (
-
+
)}
- {chatVisible && }
+ {isChatVisible && }
);
}
diff --git a/web/components/ui/Header/Header.tsx b/web/components/ui/Header/Header.tsx
index c9ca0f857..d3f7e2076 100644
--- a/web/components/ui/Header/Header.tsx
+++ b/web/components/ui/Header/Header.tsx
@@ -1,8 +1,5 @@
import { Layout } from 'antd';
-import { useRecoilValue } from 'recoil';
-import { ChatState } from '../../../interfaces/application-state';
import { OwncastLogo, UserDropdown } from '../../common';
-import { chatStateAtom } from '../../stores/ClientConfigStore';
import s from './Header.module.scss';
const { Header } = Layout;
@@ -12,15 +9,13 @@ interface Props {
}
export default function HeaderComponent({ name = 'Your stream title' }: Props) {
- const chatState = useRecoilValue(chatStateAtom);
-
return (
);
}
diff --git a/web/components/ui/Sidebar/Sidebar.tsx b/web/components/ui/Sidebar/Sidebar.tsx
index c0e178cf5..987712ab3 100644
--- a/web/components/ui/Sidebar/Sidebar.tsx
+++ b/web/components/ui/Sidebar/Sidebar.tsx
@@ -3,26 +3,17 @@ import { useRecoilValue } from 'recoil';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatContainer, ChatTextField } from '../../chat';
import s from './Sidebar.module.scss';
-import {
- chatMessagesAtom,
- chatVisibilityAtom,
- chatStateAtom,
-} from '../../stores/ClientConfigStore';
-import { ChatState, ChatVisibilityState } from '../../../interfaces/application-state';
+
+import { chatMessagesAtom, appStateAtom } from '../../stores/ClientConfigStore';
+import { AppStateOptions } from '../../stores/application-state';
export default function Sidebar() {
const messages = useRecoilValue(chatMessagesAtom);
- const chatVisibility = useRecoilValue(chatVisibilityAtom);
- const chatState = useRecoilValue(chatStateAtom);
+ const appState = useRecoilValue(appStateAtom);
return (
-
-
+
+
);
diff --git a/web/components/video/OwncastPlayer.tsx b/web/components/video/OwncastPlayer.tsx
index d701d1500..b7cb7333a 100644
--- a/web/components/video/OwncastPlayer.tsx
+++ b/web/components/video/OwncastPlayer.tsx
@@ -1,11 +1,10 @@
import React from 'react';
-import { useSetRecoilState } from 'recoil';
+import { useRecoilState } from 'recoil';
import VideoJS from './player';
import ViewerPing from './viewer-ping';
import VideoPoster from './VideoPoster';
import { getLocalStorage, setLocalStorage } from '../../utils/helpers';
-import { videoStateAtom } from '../stores/ClientConfigStore';
-import { VideoState } from '../../interfaces/application-state';
+import { isVideoPlayingAtom } from '../stores/ClientConfigStore';
const PLAYER_VOLUME = 'owncast_volume';
@@ -19,8 +18,7 @@ interface Props {
export default function OwncastPlayer(props: Props) {
const playerRef = React.useRef(null);
const { source, online } = props;
-
- const setVideoState = useSetRecoilState(videoStateAtom);
+ const [videoPlaying, setVideoPlaying] = useRecoilState(isVideoPlayingAtom);
const setSavedVolume = () => {
try {
@@ -86,18 +84,19 @@ export default function OwncastPlayer(props: Props) {
player.on('playing', () => {
player.log('player is playing');
ping.start();
- setVideoState(VideoState.Playing);
+ setVideoPlaying(true);
});
player.on('pause', () => {
player.log('player is paused');
ping.stop();
+ setVideoPlaying(false);
});
player.on('ended', () => {
player.log('player is ended');
ping.stop();
- setVideoState(VideoState.Unavailable);
+ setVideoPlaying(false);
});
player.on('volumechange', handleVolume);
@@ -111,7 +110,7 @@ export default function OwncastPlayer(props: Props) {
)}
-
+ {!videoPlaying && }
);
diff --git a/web/interfaces/application-state.ts b/web/interfaces/application-state.ts
deleted file mode 100644
index 869dd87f0..000000000
--- a/web/interfaces/application-state.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-export enum AppState {
- Loading, // Initial loading state as config + status is loading.
- Registering, // Creating a default anonymous chat account.
- Online, // Stream is active.
- Offline, // Stream is not active.
- OfflineWaiting, // Period of time after going offline chat is still available.
- Banned, // Certain features are disabled for this single user.
-}
-
-export enum ChatVisibilityState {
- Hidden, // The chat components are not available to the user.
- Visible, // The chat components are not available to the user visually.
-}
-
-export enum ChatState {
- Available = 'Available', // Normal state. Chat can be visible and used.
- NotAvailable = 'NotAvailable', // Chat features are not available.
- Loading = 'Loading', // Chat is connecting and loading history.
- Offline = 'Offline', // Chat is offline/disconnected for some reason but is visible.
-}
-
-export enum VideoState {
- Available, // Play button should be visible and the user can begin playback.
- Unavailable, // Play button not be visible and video is not available.
- Playing, // Playback is taking place and the play button should not be shown.
-}
-
-export function getChatState(state: AppState): ChatState {
- switch (state) {
- case AppState.Loading:
- return ChatState.NotAvailable;
- case AppState.Banned:
- return ChatState.NotAvailable;
- case AppState.Online:
- return ChatState.Available;
- case AppState.Offline:
- return ChatState.NotAvailable;
- case AppState.OfflineWaiting:
- return ChatState.Available;
- case AppState.Registering:
- return ChatState.Loading;
- default:
- return ChatState.Offline;
- }
-}
-
-export function getChatVisibilityState(state: AppState): ChatVisibilityState {
- switch (state) {
- case AppState.Loading:
- return ChatVisibilityState.Hidden;
- case AppState.Banned:
- return ChatVisibilityState.Hidden;
- case AppState.Online:
- return ChatVisibilityState.Visible;
- case AppState.Offline:
- return ChatVisibilityState.Hidden;
- case AppState.OfflineWaiting:
- return ChatVisibilityState.Visible;
- case AppState.Registering:
- return ChatVisibilityState.Visible;
- default:
- return ChatVisibilityState.Hidden;
- }
-}
diff --git a/web/package-lock.json b/web/package-lock.json
index 7e23c9571..7c3309a12 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -11,6 +11,7 @@
"@ant-design/icons": "4.7.0",
"@emoji-mart/data": "^1.0.1",
"@storybook/react": "^6.4.22",
+ "@xstate/react": "^3.0.0",
"antd": "^4.20.4",
"autoprefixer": "^10.4.4",
"chart.js": "3.7.0",
@@ -40,7 +41,8 @@
"slate-react": "^0.79.0",
"storybook-addon-designs": "^6.2.1",
"ua-parser-js": "1.0.2",
- "video.js": "^7.18.1"
+ "video.js": "^7.18.1",
+ "xstate": "^4.32.1"
},
"devDependencies": {
"@babel/core": "^7.17.9",
@@ -12147,6 +12149,28 @@
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"dev": true
},
+ "node_modules/@xstate/react": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@xstate/react/-/react-3.0.0.tgz",
+ "integrity": "sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==",
+ "dependencies": {
+ "use-isomorphic-layout-effect": "^1.0.0",
+ "use-sync-external-store": "^1.0.0"
+ },
+ "peerDependencies": {
+ "@xstate/fsm": "^2.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "xstate": "^4.31.0"
+ },
+ "peerDependenciesMeta": {
+ "@xstate/fsm": {
+ "optional": true
+ },
+ "xstate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -31588,6 +31612,14 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz",
+ "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/util": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
@@ -32553,6 +32585,15 @@
"dev": true,
"peer": true
},
+ "node_modules/xstate": {
+ "version": "4.32.1",
+ "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.32.1.tgz",
+ "integrity": "sha512-QYUd+3GkXZ8i6qdixnOn28bL3EvA++LONYL/EMWwKlFSh/hiLndJ8YTnz77FDs+JUXcwU7NZJg7qoezoRHc4GQ==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/xstate"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -41647,6 +41688,15 @@
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"dev": true
},
+ "@xstate/react": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@xstate/react/-/react-3.0.0.tgz",
+ "integrity": "sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==",
+ "requires": {
+ "use-isomorphic-layout-effect": "^1.0.0",
+ "use-sync-external-store": "^1.0.0"
+ }
+ },
"@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -56627,6 +56677,12 @@
"use-isomorphic-layout-effect": "^1.0.0"
}
},
+ "use-sync-external-store": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz",
+ "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==",
+ "requires": {}
+ },
"util": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
@@ -57408,6 +57464,11 @@
"dev": true,
"peer": true
},
+ "xstate": {
+ "version": "4.32.1",
+ "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.32.1.tgz",
+ "integrity": "sha512-QYUd+3GkXZ8i6qdixnOn28bL3EvA++LONYL/EMWwKlFSh/hiLndJ8YTnz77FDs+JUXcwU7NZJg7qoezoRHc4GQ=="
+ },
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/web/package.json b/web/package.json
index 8aac6125b..487f657fa 100644
--- a/web/package.json
+++ b/web/package.json
@@ -15,6 +15,7 @@
"@ant-design/icons": "4.7.0",
"@emoji-mart/data": "^1.0.1",
"@storybook/react": "^6.4.22",
+ "@xstate/react": "^3.0.0",
"antd": "^4.20.4",
"autoprefixer": "^10.4.4",
"chart.js": "3.7.0",
@@ -44,7 +45,8 @@
"slate-react": "^0.79.0",
"storybook-addon-designs": "^6.2.1",
"ua-parser-js": "1.0.2",
- "video.js": "^7.18.1"
+ "video.js": "^7.18.1",
+ "xstate": "^4.32.1"
},
"devDependencies": {
"@babel/core": "^7.17.9",
diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx
index 2810afa13..44f1ccd1e 100644
--- a/web/pages/_app.tsx
+++ b/web/pages/_app.tsx
@@ -23,6 +23,7 @@ import '../styles/offline-notice.scss';
import { AppProps } from 'next/app';
import { Router, useRouter } from 'next/router';
+import { RecoilRoot } from 'recoil';
import AdminLayout from '../components/layouts/admin-layout';
import SimpleLayout from '../components/layouts/SimpleLayout';
@@ -31,7 +32,11 @@ function App({ Component, pageProps }: AppProps) {
if (router.pathname.startsWith('/admin')) {
return ;
}
- return ;
+ return (
+
+
+
+ );
}
export default App;
diff --git a/web/pages/embed/video/index.tsx b/web/pages/embed/video/index.tsx
index caf295789..4001bd88f 100644
--- a/web/pages/embed/video/index.tsx
+++ b/web/pages/embed/video/index.tsx
@@ -1,10 +1,34 @@
+import React from 'react';
+import { useRecoilValue } from 'recoil';
+import {
+ ClientConfigStore,
+ isOnlineSelector,
+ serverStatusState,
+} from '../../../components/stores/ClientConfigStore';
+import OfflineBanner from '../../../components/ui/OfflineBanner/OfflineBanner';
+import Statusbar from '../../../components/ui/Statusbar/Statusbar';
import OwncastPlayer from '../../../components/video/OwncastPlayer';
+import { ServerStatus } from '../../../interfaces/server-status.model';
export default function VideoEmbed() {
- const online = false;
+ const status = useRecoilValue(serverStatusState);
+
+ // const { extraPageContent, version, socialHandles, name, title, tags } = clientConfig;
+ const { viewerCount, lastConnectTime, lastDisconnectTime } = status;
+ const online = useRecoilValue(isOnlineSelector);
return (
-
-
-
+ <>
+
+
+ {online && }
+ {!online && }{' '}
+
+
+ >
);
}
diff --git a/web/pages/index.tsx b/web/pages/index.tsx
index 6fe751c09..f0367c0ca 100644
--- a/web/pages/index.tsx
+++ b/web/pages/index.tsx
@@ -1,10 +1,5 @@
-import { RecoilRoot } from 'recoil';
import Main from '../components/layouts/Main';
export default function Home() {
- return (
-
-
-
- );
+ return ;
}
diff --git a/web/stories/ChatContainer.stories.tsx b/web/stories/ChatContainer.stories.tsx
index 77d50a769..1a0942d7d 100644
--- a/web/stories/ChatContainer.stories.tsx
+++ b/web/stories/ChatContainer.stories.tsx
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ChatContainer from '../components/chat/ChatContainer';
import { ChatMessage } from '../interfaces/chat-message.model';
-import { ChatState } from '../interfaces/application-state';
export default {
title: 'owncast/Chat/Chat messages container',
@@ -25,7 +24,7 @@ const testMessages =
const messages: ChatMessage[] = JSON.parse(testMessages);
const AddMessagesChatExample = args => {
- const { messages: m, state } = args;
+ const { messages: m, loading } = args;
const [chatMessages, setChatMessages] = useState(m);
return (
@@ -33,7 +32,7 @@ const AddMessagesChatExample = args => {
-
+
);
};
@@ -42,17 +41,17 @@ const Template: ComponentStory = args => = args => ;
-const Template: ComponentStory = args => ;
+const Template: ComponentStory = args => (
+
+
+
+);
export const LiveDemo = Template.bind({});
LiveDemo.args = {