Something went wrong. Try again.
[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
Something went wrong. Try again.
11 kB · 277 lines
TSX
at strict
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277import { type ComponentProps, useState, useCallback, type ReactNode, type PropsWithChildren } from "react"import { Badge, Stat, HStack, type BadgeProps } from '@chakra-ui/react';import type {ComponentClientApiJson, ComponentCommonApiJson, MsSseEvent} from "../../../core/Api.js";import { TextMuted } from "../TextMuted.js";import type { IconBaseProps } from "react-icons/lib";import { DownArrowIcon, UpArrowIcon } from "../icons/ChakraIcons.js";import { useTimeout } from 'react-use-timeout';import { useSSEContext, useSSEAnyEvent} from "@flamefrontend/sse-runtime-react";import dayjs from "dayjs";import { shortTodayAwareFormat } from "../../../core/TimeUtils.js";import { TextTip } from "../ToggleTip.js";
type Color = BadgeProps['colorPalette'] & IconBaseProps['color'];
export const StaticStat = (props: PropsWithChildren<{ label: string | ReactNode helpText?: string | ReactNode}> & ComponentProps<typeof Stat.Root>) => { const { children, label, helpText, ...rest } = props; const presentationText = helpText !== undefined ? <TextTip text={label}>{helpText}</TextTip> : label return ( <Stat.Root size={{ smDown: "sm", base: "md" }} {...rest}> <Stat.Label>{presentationText}</Stat.Label> <Stat.ValueText textWrapMode="nowrap">{children}</Stat.ValueText> </Stat.Root> );}
export const Indicator = (props: { current?: number currentText: string | ReactNode recentTimeout?: number total?: number totalText?: string | ReactNode helpText?: string | ReactNode directionColors?: [Color, Color] as?: 'text' | 'stat'} & ComponentProps<typeof Stat.Root>) => {
const { as = 'stat', current = 0, currentText, recentTimeout: timeoutProp = 10000, directionColors = ['green', 'red'], total, totalText, helpText, ...rest } = props;
const [lastCurrent, setLastCurrent] = useState(current); const [recent, setRecent] = useState(0); const [recentDirection, setRecentDirection] = useState<'up' | 'down'>('up'); const resetRecent = useCallback(() => { setRecent(0);
}, [setRecent]); const recentTimeout = useTimeout(resetRecent, timeoutProp ?? 10000);
if (lastCurrent !== current) { recentTimeout.stop(); if (lastCurrent > current) { if (recentDirection === 'up') { setRecent(1); setRecentDirection('down'); } else { setRecent(recent + 1); } } else { if (recentDirection === 'down') { setRecent(1); setRecentDirection('up'); } else { setRecent(recent + 1); } } setLastCurrent(current); recentTimeout.start(); }
let contextText: string | ReactNode; if(helpText !== undefined) { contextText = helpText; } if(totalText !== undefined) { contextText = <>{currentText} and ({totalText})</>; } const presentationText = contextText !== undefined ? <TextTip text={currentText}>{contextText}</TextTip> : currentText
if (as === 'stat') { return ( <Stat.Root size={{ smDown: "sm", base: "md" }} {...rest}> <Stat.Label>{presentationText}</Stat.Label> <HStack> <Stat.ValueText textWrapMode="nowrap">{current} {total !== undefined && <>({total})</>}</Stat.ValueText> {recent !== 0 ? <Badge colorPalette={recentDirection === 'up' ? directionColors[0] : directionColors[1]} gap="0"> {recentDirection === 'up' ? <Stat.UpIndicator color={directionColors[0]} /> : <Stat.DownIndicator color={directionColors[1]} />} {recent} </Badge> : null} </HStack> </Stat.Root> ); }
return ( <TextMuted textStyle="sm">{current} {total !== undefined && <>({total})</>} {recent !== 0 ? <Badge size="sm" colorPalette={recentDirection === 'up' ? directionColors[0] : directionColors[1]} gap="0"> {recentDirection === 'up' ? <UpArrowIcon color={directionColors[0]} /> : <DownArrowIcon color={directionColors[1]} />} {recent} </Badge> : null} {currentText}</TextMuted> );};
export const CountIndicatorStatic = (props: Omit<ComponentProps<typeof Indicator>, 'totalText' | 'helpText' | 'directionColors' | 'currentText'> & Pick<ComponentCommonApiJson, 'mode'>) => ( <Indicator helpText="Since Start and (Total)" currentText={props.mode === 'source' ? 'Discovered' : 'Scrobbled'} {...props} />)
export const CountIndicatorStreamable = (props: { data: Pick<ComponentCommonApiJson, 'countLive' | 'id' | 'mode'> & { tracksDiscovered?: number, tracksScrobbled?: number } } & Omit<ComponentProps<typeof CountIndicatorStatic>, 'mode'>) => { const { data, ...rest } = props; const [statsData, setStatsData] = useState({current: data.tracksDiscovered ?? data.tracksScrobbled, total: data.countLive});
const client = useSSEContext<MsSseEvent>(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === data.id) { switch (payload.type) { case 'scrobble': case 'discovered': setStatsData({current: statsData.current + 1, total: statsData.total + 1}); break; } } });
return <CountIndicatorStatic current={statsData.current} total={statsData.total} mode={data.mode} {...rest} />}
export const QueuedIndicatorStatic = (props: Omit<ComponentProps<typeof Indicator>, 'currentText' | 'totalText' | 'helpText' | 'directionColors'>) => ( <Indicator currentText="Queued" directionColors={['red', 'green']} {...props} />);
export const QueuedIndicatorStreamable = (props: { data: Pick<ComponentClientApiJson, 'id' | 'queued'> } & ComponentProps<typeof QueuedIndicatorStatic>) => { const { data, ...rest } = props; const [statsData, setStatsData] = useState({current: data.queued});
const client = useSSEContext<MsSseEvent>(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === data.id) { switch (payload.type) { case 'playQueued': setStatsData({current: statsData.current + 1}); break; case 'playDequeued': setStatsData({current: statsData.current - 1}); break; } } });
return <QueuedIndicatorStatic current={statsData.current} {...rest} />}
export const DeadLetterIndicatorStatic = (props: Omit<ComponentProps<typeof Indicator>, 'currentText' | 'totalText' | 'helpText' | 'directionColors'>) => ( <Indicator currentText="Failed" directionColors={['red', 'green']} helpText="To Be Retried and Total" {...props} />);
export const DeadLetterIndicatorStreamable = (props: { data: Pick<ComponentClientApiJson, 'id' | 'deadLetterPlays' | 'deadLetterPlaysTotal'> } & ComponentProps<typeof DeadLetterIndicatorStatic>) => { const { data, ...rest } = props; const [statsData, setStatsData] = useState({current: data.deadLetterPlays, total: data.deadLetterPlaysTotal});
const client = useSSEContext<MsSseEvent>(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === data.id) { switch (payload.type) { case 'deadLetter': setStatsData({current: statsData.current, total: statsData.total + 1}); break; case 'deadLetterRemoved': case 'removeDeadLetter': setStatsData({current: statsData.current, total: statsData.total - 1}); break; case 'deadLetterDequeued': setStatsData({current: statsData.current - 1, total: statsData.total}); break; case 'deadQueued': setStatsData({current: statsData.current + 1, total: statsData.total}); break; } } });
return <DeadLetterIndicatorStatic current={statsData.current} total={statsData.total} {...rest} />}
export const DateIndicatorStreamable = (props: Omit<ComponentProps<typeof DateIndicator>, 'streamedDate'>) => {
const { data: { id, lastActiveAt, lastReadyAt, state } = {}, } = props;
const useActive = state < 5 const [current, setCurrent] = useState(useActive ? lastActiveAt : lastReadyAt);
const client = useSSEContext<MsSseEvent>(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === id) { // TODO update state from event setCurrent(dayjs().toISOString()); } });
return <DateIndicator streamedDate={current} {...props}/>}
export const DateIndicator = (props: { data: Pick<ComponentCommonApiJson, 'id' | 'lastActiveAt' | 'state' | 'lastReadyAt'>, streamable?: boolean as?: 'text' | 'stat' streamedDate?: string} & ComponentProps<typeof Stat.Root>) => {
const { data: { lastActiveAt, lastReadyAt, state } = {}, streamable, as = 'stat', ...rest } = props;
const useActive = state < 5; let usedDate: string = props.streamedDate; if(usedDate === undefined) { usedDate = useActive ? lastActiveAt : lastReadyAt; }
if(as === 'stat') { return ( <Stat.Root size={{smDown: "sm", base: "md"}} {...rest}> <Stat.Label>{useActive? 'Last Active At' : 'Last Ready At'}</Stat.Label> <HStack> <Stat.ValueText>{shortTodayAwareFormat(dayjs(usedDate))}</Stat.ValueText> </HStack> </Stat.Root> ); }
return ( <TextMuted textStyle="sm">{shortTodayAwareFormat(dayjs(usedDate))} {useActive? 'Last Active At' : 'Last Ready At'}</TextMuted> );}