[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): Implement header

FoxxMD (Jun 15, 2026, 9:08 PM UTC) d1ef09ba d0736422

+130 -17
+3 -3
src/client/AppNext.tsx
··· 19 19 import { Container, Box, Center } from '@chakra-ui/react'; 20 20 import { MsSseEvent } from '../core/Api'; 21 21 import { SSEProvider } from "@flamefrontend/sse-runtime-react"; 22 + import { AppHeader } from './components/AppHeader'; 22 23 23 24 function NoMatch() { 24 25 const location = useLocation(); ··· 59 60 const routes: RouteObject[] = [ 60 61 { 61 62 path: "/next", 62 - element: <Center><MSComponentListFetchable/></Center>, 63 + element: <Container maxWidth="4xl"><MSComponentListFetchable/></Container>, 63 64 }, 64 65 { 65 66 path: "/docs", ··· 86 87 function App() { 87 88 return ( 88 89 <Provider> 89 - <Container maxWidth="8xl"> 90 + <Box px="4" py="2" pb="4"><AppHeader fetchable/></Box> 90 91 <SSEProvider<MsSseEvent> options={sseProviderOptions}> 91 92 <RouterProvider router={router}/> 92 93 </SSEProvider> 93 - </Container> 94 94 </Provider> 95 95 ); 96 96 }
+32 -1
src/client/Version.tsx
··· 1 - import React from 'react'; 1 + import React, {ComponentProps} from 'react'; 2 + import { QueryFunctionContext, queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; 2 3 import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react"; 4 + import { Span } from '@chakra-ui/react'; 5 + 6 + import ky from 'ky'; 7 + import { baseUrl } from './utils'; 8 + import { TextMuted } from './components/TextMuted'; 3 9 4 10 export const versionApi = createApi({ 5 11 reducerPath: 'versionApi', ··· 24 30 } 25 31 26 32 export default Version; 33 + 34 + export const VersionNext = (props: ComponentProps<typeof TextMuted> = {}) => { 35 + 36 + const { isPending, isError, data, error } = useQuery({ 37 + queryKey: ['version'], 38 + queryFn: queryFn, 39 + staleTime: Infinity, 40 + }); 41 + 42 + if (isError) { 43 + return <TextMuted textStyle="xs" {...props}>error.message</TextMuted>; 44 + } 45 + 46 + if(!isPending) { 47 + return <TextMuted textStyle="xs" overflow="clip" {...props}>{data.version}</TextMuted>; 48 + } 49 + 50 + return null; 51 + 52 + } 53 + 54 + type VersionQueryKey = ['version']; 55 + const queryFn = async (context: QueryFunctionContext<VersionQueryKey>) => { 56 + return await ky.get(`version`, { baseUrl: baseUrl }).json() as {version: string}; 57 + }
+54
src/client/components/AppHeader.tsx
··· 1 + import React, { ComponentProps, useMemo, forwardRef, Fragment, useEffect } from "react" 2 + import { Image, Heading, HStack, Link, LinkOverlay, LinkBox, Span, Flex, Box, Separator } from '@chakra-ui/react'; 3 + import { VersionNext } from "../Version"; 4 + import { TextMuted } from "./TextMuted"; 5 + import { DocsButton, GithubButton, HeartbeatButton, HeartbeatIcon, TerminalButton } from "./icons/ChakraIcons"; 6 + 7 + export const AppTitle = (props: { fetchable?: boolean } = {}) => { 8 + const { 9 + fetchable 10 + } = props; 11 + 12 + return ( 13 + <HStack gap="2"> 14 + <LinkBox> 15 + <HStack gap="2"> 16 + <Image flex="0" maxWidth="30px" height="100%" width="100%" src="/icon.svg"></Image> 17 + <LinkOverlay href="/next/"> 18 + <Heading hideBelow="sm" size="sm">Multi Scrobbler</Heading> 19 + </LinkOverlay> 20 + </HStack> 21 + </LinkBox> 22 + {fetchable ? <VersionNext /> : <TextMuted>dev</TextMuted>} 23 + </HStack> 24 + ) 25 + } 26 + 27 + export const RightHeaderActions = (props: any) => { 28 + return <HStack gap="2"> 29 + <LinkBox> 30 + <LinkOverlay target="__blank" href="https://status.multi-scrobbler.app"> 31 + <HeartbeatButton /> 32 + </LinkOverlay> 33 + </LinkBox> 34 + <LinkBox> 35 + <LinkOverlay target="__blank" href="https://ms.foxxmd.io/docs"> 36 + <DocsButton /> 37 + </LinkOverlay> 38 + </LinkBox> 39 + <LinkBox> 40 + <LinkOverlay target="__blank" href="https://github.com/FoxxMD/multi-scrobbler"> 41 + <GithubButton /> 42 + </LinkOverlay> 43 + </LinkBox> 44 + </HStack> 45 + } 46 + 47 + export const AppHeader = (props: { fetchable?: boolean } = {}) => { 48 + return ( 49 + <Flex justify="space-between"> 50 + <AppTitle fetchable={props.fetchable} /> 51 + <Flex justify="flex-start" alignItems="flex-end"><RightHeaderActions /></Flex> 52 + </Flex> 53 + ) 54 + }
+1 -1
src/client/components/Provider.tsx
··· 22 22 <ChakraProvider value={defaultSystem}> 23 23 <ColorModeProvider {...props} /> 24 24 </ChakraProvider> 25 - <ReactQueryDevtools initialIsOpen={true} /> 25 + {/* <ReactQueryDevtools /> */} 26 26 </QueryClientProvider> 27 27 ) 28 28 }
-1
src/client/components/chakraPlayer/Player.tsx
··· 124 124 const { isPending, isError, data, error } = useQuery({ 125 125 queryKey: ['components', componentId, 'players', platformId], 126 126 queryFn: queryFn, 127 - structuralSharing: false, 128 127 staleTime: Infinity, 129 128 }); 130 129
+30 -1
src/client/components/icons/ChakraIcons.tsx
··· 1 - import { LuChevronRight } from "react-icons/lu" 1 + import { LuChevronRight, LuActivity, LuGithub, LuTerminal } from "react-icons/lu" 2 + import { SiGoogledocs } from "react-icons/si"; 2 3 import { IconButton } from "@chakra-ui/react" 3 4 import { ComponentProps } from 'react'; 4 5 ··· 6 7 export const ChevronRightButton = (props: ComponentProps<typeof IconButton>) => ( 7 8 <IconButton variant="surface" size="xs" {...props}> 8 9 <ChevronRight /> 10 + </IconButton> 11 + ); 12 + 13 + export const HeartbeatIcon = LuActivity; 14 + export const HeartbeatButton = (props: ComponentProps<typeof IconButton>) => ( 15 + <IconButton variant="surface" size="xs" {...props}> 16 + <HeartbeatIcon /> 17 + </IconButton> 18 + ); 19 + 20 + export const GithubIcon = LuGithub; 21 + export const GithubButton = (props: ComponentProps<typeof IconButton>) => ( 22 + <IconButton variant="surface" size="xs" {...props}> 23 + <GithubIcon /> 24 + </IconButton> 25 + ); 26 + 27 + export const DocsIcon = SiGoogledocs; 28 + export const DocsButton = (props: ComponentProps<typeof IconButton>) => ( 29 + <IconButton variant="surface" size="xs" {...props}> 30 + <DocsIcon /> 31 + </IconButton> 32 + ); 33 + 34 + export const TerminalIcon = LuTerminal; 35 + export const TerminalButton = (props: ComponentProps<typeof IconButton>) => ( 36 + <IconButton variant="surface" size="xs" {...props}> 37 + <TerminalIcon /> 9 38 </IconButton> 10 39 );
+10 -10
src/stories/Header.stories.ts src/stories/Header.stories.tsx
··· 1 1 import preview from "../../.storybook/preview.js"; 2 2 3 - import { fn } from 'storybook/test'; 4 - 5 - import { Header } from './Header.js'; 3 + import { AppHeader } from "../client/components/AppHeader.js"; 4 + import {Provider} from "../client/components/Provider"; 6 5 7 6 const meta = preview.meta({ 8 7 title: 'Example/Header', 9 - component: Header, 8 + component: AppHeader, 10 9 // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 11 10 tags: ['autodocs'], 12 11 parameters: { 13 12 // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 14 - layout: 'fullscreen', 15 - }, 16 - args: { 17 - onLogin: fn(), 18 - onLogout: fn(), 19 - onCreateAccount: fn(), 13 + layout: 'padded', 20 14 }, 15 + render: function Render(args) { 16 + return (<AppHeader/>) 17 + }, 18 + decorators: [ 19 + (Story) => (<Provider><Story/></Provider>), 20 + ] 21 21 }); 22 22 23 23 export const LoggedIn = meta.story({