diff --git a/package-lock.json b/package-lock.json index 915d59e6..9bf1d07c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,6 +114,7 @@ "@emotion/react": "^11.14.0", "@eslint/js": "^10.0.1", "@faker-js/faker": "^9.0.1", + "@flamefrontend/sse-runtime-react": "^0.7.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.6", @@ -1843,6 +1844,26 @@ "npm": ">=9.0.0" } }, + "node_modules/@flamefrontend/sse-runtime-core": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@flamefrontend/sse-runtime-core/-/sse-runtime-core-0.10.0.tgz", + "integrity": "sha512-TUd7Uv/NkYI0qCv2EzsCRnCLqBRxpMJDc4CZVUcpxIZ6ol8gOW8SAvIm3COyRrqkDCZjOX6hHoQjtYIiOuA6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@flamefrontend/sse-runtime-react": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@flamefrontend/sse-runtime-react/-/sse-runtime-react-0.7.0.tgz", + "integrity": "sha512-djPvMmeNTLRS+78vDjSh6iVRiPg92f9KJtZ9d1YAmlwnzxIjg80VlyUAcOOjb0xhfMNkkDcG+v+iLVaqkgIY7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@flamefrontend/sse-runtime-core": "0.10.0" + }, + "peerDependencies": { + "react": "^18.3.0 || ^19.0.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", diff --git a/package.json b/package.json index 5c3a60c4..324dce7e 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "@emotion/react": "^11.14.0", "@eslint/js": "^10.0.1", "@faker-js/faker": "^9.0.1", + "@flamefrontend/sse-runtime-react": "^0.7.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.6", diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index fb6ac4cd..34abd02e 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -147,15 +147,31 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro }); app.get('/api/events', async (req, res) => { + const { + query: { + next: nextQs + } + } = req; + + const isNextapi = nextQs === 'true'; + const session = await bsseDef.createSession(req, res); scrobbleSources.emitter.on('*', (payload: any, eventName: string) => { if(payload.from !== undefined) { - session.push({event: eventName, ...payload}, payload.from); + if(isNextapi) { + session.push({event: eventName, ...payload}, eventName); + } else { + session.push({event: eventName, ...payload}, payload.from); + } } }); scrobbleClients.emitter.on('*', (payload: any, eventName: string) => { if(payload.from !== undefined) { - session.push({event: eventName, ...payload}, payload.from); + if(isNextapi) { + session.push({event: eventName, ...payload}, eventName); + } else { + session.push({event: eventName, ...payload}, payload.from); + } } }); }); diff --git a/src/client/AppNext.tsx b/src/client/AppNext.tsx index eb3e0448..1a9b9081 100644 --- a/src/client/AppNext.tsx +++ b/src/client/AppNext.tsx @@ -13,14 +13,12 @@ import Dashboard from "./dashboard/dashboard"; import RecentPage from "./recent/RecentPage"; import ScrobbledPage from "./scrobbled/ScrobbledPage"; import DeadPage from "./deadLetter/DeadPage"; -import {clientUpdate, sourceUpdate} from "./status/ducks"; -import {useEventSource, useEventSourceListener} from "@react-nano/use-event-source"; import Version from "./Version"; import { MSComponentList, MSComponentListFetchable } from './components/msComponent/MSComponentList'; import { Provider } from './components/Provider'; -import { Container } from '@chakra-ui/react'; -import { useQueryClient } from '@tanstack/react-query' -import { ComponentsApiJson, ComponentSourceApiJson } from '../core/Api'; +import { Container, Box, Center } from '@chakra-ui/react'; +import { MsSseEvent } from '../core/Api'; +import { SSEProvider } from "@flamefrontend/sse-runtime-react"; function NoMatch() { const location = useLocation(); @@ -61,7 +59,7 @@ function MissingDocs() { const routes: RouteObject[] = [ { path: "/next", - element: , + element:
, }, { path: "/docs", @@ -80,84 +78,18 @@ const genRouter = () => { const router = genRouter(); -// const mapDispatchToProps = (dispatch) => { -// return { -// updateSource: (payload) => dispatch(sourceUpdate(payload)), -// updateClient: (payload) => dispatch(clientUpdate(payload)) -// } -// } - -// const connector = connect(null, mapDispatchToProps); - -// type PropsFromRedux = ConnectedProps; - -// const Global = (props: PropsFromRedux) => { -// const { -// updateSource, -// updateClient -// } = props; - -// const [sourceEventSource, eventSourceStatus] = useEventSource("api/events", false); -// useEventSourceListener(sourceEventSource, ['source', 'client'], evt => { -// const data = JSON.parse(evt.data); -// if(data.from === 'source') { -// updateSource(data); -// } else if(data.from === 'client') { -// updateClient(data); -// } -// }, [updateSource, updateClient]); - -// return ; -// } - -// const ConnectedGlobal = connector(Global); - -const Global = (props) => { - - const queryClient = useQueryClient(); - const [sourceEventSource, eventSourceStatus] = useEventSource("/api/events", false); - useEventSourceListener(sourceEventSource, ['source', 'client'], evt => { - const data = JSON.parse(evt.data); - if(data.event === 'playerUpdate') { - - console.log(`Updated => Component ${data.componentId} Player ${data.data.platformId}`); - queryClient.setQueryData(['components', data.componentId, 'players', data.data.platformId], data.data); - - // queryClient.setQueryData(['components'], (old: ComponentsApiJson[]) => { - // const componentIndex = old.findIndex(x => x.id === data.componentId); - // if(componentIndex !== -1) { - // console.log(`Updated => Component ${data.componentId} Player ${data.data.platformId}`); - // //(old[componentIndex] as ComponentSourceApiJson).players[data.data.platformId] = data.data; - // const newData = [...old]; - // newData[componentIndex] = { - // ...old[componentIndex], - // players: { - // // @ts-ignore - // ...old[componentIndex].players, - // [data.data.platformId]: data.data - // } - // } - // return newData; - // } - // //console.log(old); - // }); - } - if(data.from === 'source') { - console.log(data); - } else if(data.from === 'client') { - console.log(data); - } - }, []); - - return ; +const sseProviderOptions = { + key: ['events'], + url: '/api/events?next=true' } function App() { return ( - + options={sseProviderOptions}> + ); diff --git a/src/client/components/chakraPlayer/Player.tsx b/src/client/components/chakraPlayer/Player.tsx index 5b8039f2..99bc7641 100644 --- a/src/client/components/chakraPlayer/Player.tsx +++ b/src/client/components/chakraPlayer/Player.tsx @@ -8,6 +8,11 @@ import { QueryFunctionContext, queryOptions, useQuery, useQueryClient } from '@t import { ErrorAlert } from "../ErrorAlert"; import ky from 'ky'; import { baseUrl } from "../../utils"; +import { + useSSEContext, + useSSEEvent, +} from "@flamefrontend/sse-runtime-react"; +import { MsSseEvent, MsSseEventPayload } from "../../../core/Api"; export interface PlayerProps { data: SourcePlayerJson @@ -102,11 +107,19 @@ export const ChakraPlayerFetchable = (props: ChakraPlayerFetchableProps) => { sot } = props; const queryClient = useQueryClient(); + const qKey = ['components', componentId, 'players', platformId]; useEffect(() => { - if(initData !== undefined) { + if (initData !== undefined && queryClient.getQueryData(qKey) === undefined) { queryClient.setQueryData(['components', componentId, 'players', platformId], initData); } - },[initData]); + }, [initData]); + + const client = useSSEContext>>(); + useSSEEvent(client, "playerUpdate", (payload) => { + if (payload.componentId === componentId && payload.data.platformId === platformId) { + queryClient.setQueryData(['components', componentId, 'players', platformId], payload.data); + } + }); const { isPending, isError, data, error } = useQuery({ queryKey: ['components', componentId, 'players', platformId], @@ -115,14 +128,12 @@ export const ChakraPlayerFetchable = (props: ChakraPlayerFetchableProps) => { staleTime: Infinity, }); - if(isError) { - return + if (isError) { + return } - console.log('rendering player'); - - if(!isPending) { - return + if (!isPending) { + return } } diff --git a/src/client/components/msComponent/MSComponentList.tsx b/src/client/components/msComponent/MSComponentList.tsx index dc9cdd6d..c3e3dcaa 100644 --- a/src/client/components/msComponent/MSComponentList.tsx +++ b/src/client/components/msComponent/MSComponentList.tsx @@ -1,7 +1,7 @@ import React, { ComponentProps, useMemo, useState, forwardRef, Fragment } from "react" import { Stack, SegmentGroup, Text, Box, Center, Card, SkeletonText } from '@chakra-ui/react'; import { ComponentsApiJson } from "../../../core/Api"; -import { MSComponentSummary } from "./MSComponentSummary"; +import { MSComponentSummary, MSComponentSummaryFetchable } from "./MSComponentSummary"; import { QueryFunctionContext, queryOptions, useQuery } from '@tanstack/react-query'; import ky from 'ky'; import { baseUrl } from "../../utils"; @@ -9,6 +9,7 @@ import { ErrorAlert } from "../ErrorAlert"; export interface ComponentListProps { components: ComponentsApiJson[] + fetchable?: boolean } export const MSComponentList = (props: ComponentListProps) => { @@ -31,7 +32,7 @@ export const MSComponentList = (props: ComponentListProps) => { return x.mode === 'source'; } return x.mode === 'client'; - }).map(x => )} + }).map(x => props.fetchable ? : )} ) @@ -56,7 +57,7 @@ export const MSComponentListFetchable = () => { return } - return + return } type ComponentListQueryKey = ['components']; diff --git a/src/client/components/msComponent/MSComponentSummary.tsx b/src/client/components/msComponent/MSComponentSummary.tsx index 31701d31..8a3380c4 100644 --- a/src/client/components/msComponent/MSComponentSummary.tsx +++ b/src/client/components/msComponent/MSComponentSummary.tsx @@ -1,6 +1,6 @@ -import React, { ComponentProps, useMemo, forwardRef, Fragment } from "react" +import React, { ComponentProps, useMemo, forwardRef, Fragment, useEffect } from "react" import { Accordion, For, Span, Stack, Text, Box, Heading, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Collapsible, Card, LinkOverlay, LinkBox } from '@chakra-ui/react'; -import { ComponentCommonApi, ComponentCommonApiJson, isComponentClientApiJson, isComponentSourceApiJson } from "../../../core/Api.js"; +import { ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentSourceApiJson, isComponentClientApiJson, isComponentSourceApiJson, MsSseEvent } from "../../../core/Api.js"; import { TextMuted } from "../TextMuted.js"; import { isClientType } from "../../../backend/common/infrastructure/Atomic.js"; import { capitalize } from "../../../core/StringUtils.js"; @@ -8,10 +8,20 @@ import { ShortDateDisplay } from "../DateDisplay.js"; import { ChevronRightButton } from "../icons/ChakraIcons.js"; import { ChakraPlayer, ChakraPlayerFetchable } from "../chakraPlayer/Player.js"; import { InfoTip } from "../ToggleTip.js"; +import { QueryFunctionContext, queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ErrorAlert } from "../ErrorAlert"; +import ky from 'ky'; +import { baseUrl } from "../../utils"; +import { + useSSEContext, + useSSEEvent, + useSSEAnyEvent +} from "@flamefrontend/sse-runtime-react"; -export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => { +export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetchable?: boolean }) => { const { - data + data, + fetchable } = props; const isClient = isClientType(data.type); @@ -27,7 +37,11 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => { body = ( { - Object.entries(players).map(([key, x]) => ) + Object.entries(players).map(([key, x]) => ( + + {fetchable ? : } + + )) } ); @@ -120,4 +134,77 @@ const StateBadge = (props: ComponentProps & { data: ComponentCommo } return {badgeText} +} + + + +export const MSComponentSummaryFetchable = (props: {componentId: number, data: ComponentCommonApiJson}) => { + const { + componentId, + data: initData + } = props; + const queryClient = useQueryClient(); + const qKey = ['components', componentId, 'summary']; + useEffect(() => { + if (initData !== undefined && queryClient.getQueryData(qKey) === undefined) { + queryClient.setQueryData(['components', componentId, 'summary'], initData); + } + }, [initData]); + + const client = useSSEContext(); + useSSEAnyEvent(client, (payload) => { + if('componentId' in (payload.data as object) && (payload.data as Record).componentId === componentId) { + switch(payload.type) { + case 'discovered': + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentSourceApiJson) => { + return { + ...old, + tracksDiscovered: old.tracksDiscovered + 1 + } + }); + break; + case 'scrobbleQueued': + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentClientApiJson) => { + let newData: ComponentClientApiJson = {...old}; + newData.queued = old.queued + 1; + return newData; + }); + break; + case 'scrobbleDequeued': + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentClientApiJson) => { + let newData: ComponentClientApiJson = {...old}; + newData.queued = old.queued - 1; + return newData; + }); + break; + case 'scrobble': + queryClient.setQueryData(['components', componentId, 'summary'], (old: ComponentClientApiJson) => { + let newData: ComponentClientApiJson = {...old}; + newData.countLive = old.countLive + 1; + return newData; + }); + break; + } + } + }); + + const { isPending, isError, data, error } = useQuery({ + queryKey: ['components', componentId, 'summary'], + queryFn: queryFn, + structuralSharing: false, + staleTime: Infinity, + }); + + if (isError) { + return + } + + if(!isPending) { + return + } +} + +type ComponentSummaryQueryKey = ['components', number, 'summary']; +const queryFn = async (context: QueryFunctionContext) => { + return {} as ComponentCommonApiJson; } \ No newline at end of file diff --git a/src/core/Api.ts b/src/core/Api.ts index bb64fce8..56a3647b 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -89,4 +89,21 @@ export const isComponentSourceApiJson = (data: ComponentCommonApiJson): data is export const isComponentClientApiJson = (data: ComponentCommonApiJson): data is ComponentClientApiJson => { return data.mode === 'client'; +} + +export type MsSseEventPayload> = { + type: SourceType | ClientType + name: string + componentId: number + from: 'source' | 'client' + data: T + event: string +} + +export type MsSseEvent> = { + playerUpdate: MsSseEventPayload + discovered: MsSseEventPayload + scrobbleQueued: MsSseEventPayload + scrobbleDequeued: MsSseEventPayload + client: T } \ No newline at end of file