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.
17 kB · 398 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398import React, { type ComponentProps, useMemo, useState, useEffect } from "react"import { Em, Span, Stack, Spacer, Text, Image, Box, Heading, HStack, Flex, Center, Container, Status } from '@chakra-ui/react';import { TextMuted } from "../TextMuted";import { SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson } from "../../../core/Atomic";import { timeToHumanTimestamp } from "../../../core/TimeUtils";import { capitalize } from "../../../core/StringUtils";import { useQuery, useQueryClient } from '@tanstack/react-query';import { ErrorAlert } from "../ErrorAlert";import { useSSEAnyEvent, useSSEContext, useSSEEvent,} from "@flamefrontend/sse-runtime-react";import type {ComponentCommonApiJson, MsSseEvent, MsSseEventPayload} from "../../../core/Api";import LinearProgress from '@mui/material/LinearProgress';import { InfoTip } from "../ToggleTip";import { tanQueries } from "../../queries";import dayjs from "dayjs";import { MSErrorBoundary } from "../ErrorBoundary";import { PlaybackReportingPlayer } from "../icons/PlaybackReporting";
export interface PlayerProps { data: SourcePlayerJson & {expiration?: string} nowPlaying?: boolean sot?: SOURCE_SOT_TYPES playbackReporting?: boolean}
const bufferExplanation = (<> <Text mb="2"> The <Span bg="var(--chakra-colors-color-palette-solid)" color="var(--chakra-colors-color-palette-contrast)">reported bar</Span> is the <Em>real, reported</Em> position by the upstream service.</Text> <Text> The <Span bg="var(--chakra-colors-color-palette-500)" color="var(--chakra-colors-color-palette-contrast)">buffer bar</Span> is the <Em>calculated</Em> real-time position. </Text> <Text>This is reflected in the position timestamp and corrected when the reported position is updated.</Text></>);
export const ChakraPlayer = (props: PlayerProps) => {
const { data, nowPlaying = false, sot = SOURCE_SOT.PLAYER, playbackReporting } = props;
const { play: { data: { track = '???', artists = [{ name: '???' }], duration = 0 } = {}, meta: { art = {}, } = {} } = {}, play, listenedDuration = 0, nowPlayingMode = false, status: { calculated = '???', reported, stale, orphaned } = {}, expiration } = data;
const [positionBuffer, setProgressBuffer] = useState<undefined | number>(undefined); const [intervalId, setIntervalId] = useState<undefined | number>(undefined); const [lastUpdated, setLastUpdated] = useState<undefined | number>(undefined); const playArt = art.track ?? art.album ?? art.artist ?? undefined;
const isNowPlaying = nowPlaying || nowPlayingMode;
let durPer = null; if (!isNowPlaying) { if (duration !== undefined && duration !== null && duration !== 0) { if (listenedDuration === 0 || listenedDuration === null) { durPer = ' (0%)'; } else { durPer = ` (${((listenedDuration / duration) * 100).toFixed(0)}%)`; } } }
// this effect block is used to set the buffer progress using a realtime counter (setinterval) // we only set this as "active" if the player has reported it is calculated playing and we have position // -- then we update the buffer position to increment one second every second // this is reset to undefined if the valid player state changes to invalid (not playing) // or reset to the player position if props change but state stays valid (last reported position from upstream service) useEffect(() => { // abusing useState a little bit here... // need to clear interval on the old id if props have changed // but cannot use interval id in useEffect or it causes circular dependencies since we set intervalId here too // so clear inside the set state function (bad) using the previous data argument, before returning new value let interval; if(!isNowPlaying && data.status?.calculated === 'playing' && data.position !== undefined && !data.status?.stale && !data.status?.orphaned) { // eslint-disable-next-line react-hooks/set-state-in-effect setProgressBuffer(data.position); interval = setInterval(() => { setProgressBuffer((oldPosition) => { if(data.play.data.duration !== undefined) { return Math.min(data.play.data.duration, oldPosition + 1); } return oldPosition + 1; }); }, 1000); setIntervalId((old) => { if(old !== undefined) { clearInterval(old); } return interval; }); } else if(isNowPlaying) { interval = setInterval(() => { // force now playing-only player to re-render // so we can stop rendering it if it passes expiration date setLastUpdated(dayjs().unix()); }, 1000); setIntervalId((old) => { if(old !== undefined) { clearInterval(old); } return interval; }); } else { setProgressBuffer(undefined); if(intervalId !== undefined) { setIntervalId((old) => { if(old !== undefined) { clearInterval(old); } return undefined; }); } } return () => clearInterval(interval); },[setProgressBuffer, data, setIntervalId, setLastUpdated, isNowPlaying]);
if(expiration !== undefined && dayjs().isAfter(dayjs(expiration))) { return null; }
const indeterminate = isNowPlaying || (calculated === 'playing' && data.position === undefined); const positionProgress = indeterminate || data.position === undefined || duration === undefined ? undefined : Math.trunc((data.position/duration) * 100); const bufferProgress = indeterminate || data.position === undefined || duration === undefined || positionBuffer === undefined ? undefined : Math.trunc((positionBuffer/duration) * 100); const positionTimestamp = indeterminate || data.position === undefined ? '-' : timeToHumanTimestamp((positionBuffer ?? data.position) * 1000); const durationTimestamp = duration === undefined ? '-' : timeToHumanTimestamp(duration * 1000);
const bufferTip = positionBuffer !== undefined ? <InfoTip positioning={{placement: "bottom-start"}} buttonProps={{height: 'var(--chakra-sizes-4)'}} content={bufferExplanation}/> : null;
let playbackReportingTip: React.JSX.Element; if(playbackReporting !== undefined) { playbackReportingTip = <PlaybackReportingPlayer playbackReporting={playbackReporting} hasFields={reported !== 'unknown' || data.position !== undefined}/> } return ( <MSErrorBoundary> <Container className="playerContainer" bg="bg.emphasized" borderWidth="1px" p="2" py="3" rounded="md"> <Stack gap="2"> <Flex gap="4" align="center"> {playArt !== undefined ? <Image minWidth="48px" flex="0" height="100%" width="100%" src={playArt}></Image> : null} <Center flex="1"> <Stack textAlign="center"> <Heading textWrap="balance" size="md">{calculated !== 'stopped' ? track : '-'}</Heading> <TextMuted>{calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'}</TextMuted> </Stack> </Center> </Flex> <HStack gap="5"> <Text textStyle="sm">{positionTimestamp}</Text> <Box flex="1"> <LinearProgress variant={indeterminate ? undefined : "buffer"} value={positionProgress} valueBuffer={Math.max(bufferProgress ?? positionProgress, positionProgress)} /> </Box> <Text textStyle="xs">{durationTimestamp}</Text> </HStack> {/* <Progress.Root value={indeterminate || data.position === undefined || duration === undefined ? null : (data.position/duration) * 100} size="sm"> <HStack gap="5"> <Progress.Label>{positionTimestamp}</Progress.Label> <Progress.Track flex="1"> <Progress.Range /> </Progress.Track> <Progress.ValueText>{durationTimestamp}</Progress.ValueText> </HStack> </Progress.Root> */} <Flex alignItems="center"> <TextMuted>{['unknown', 'playing'].includes(calculated) && isNowPlaying ? 'Now Playing' : capitalize(calculated)}{!isNowPlaying ? bufferTip : null}{playbackReportingTip}</TextMuted> <Spacer /> <TextMuted>Listened: {isNowPlaying !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</TextMuted> </Flex> </Stack> </Container> </MSErrorBoundary> ) }
export const ClientNowPlaying = (props: PlayerProps) => { const { data: { play: { data: { track = '???', artists = [{ name: '???' }], duration = 0 } = {}, meta: { art = {}, } = {}, } = {}, expiration } = {} } = props;
if(expiration !== undefined && dayjs().isAfter(dayjs(expiration))) { return null; }
return ( <Stack bg="bg.emphasized" borderWidth="1px" p="2" py="3" rounded="md"> <HStack><TextMuted>Now Playing</TextMuted> <Status.Root> <Status.Indicator colorPalette="green" style={{animation: 'icon-fade-half 3s infinite linear'}}/> </Status.Root> </HStack> <Span truncate>{artists.map(x => x.name).join('/')} - {track}</Span> </Stack> )}
export interface ChakraPlayerFetchableProps { componentId: number nowPlaying?: boolean platformId: string data?: SourcePlayerJson sot?: SOURCE_SOT_TYPES}
export const ChakraPlayerFetchable = (props: ChakraPlayerFetchableProps) => { const { componentId, nowPlaying, platformId, data: initData, sot } = props; const { isPending, isError, data, error } = usePlayerQuery(componentId, platformId, {initData});
if (isError) { return <ErrorAlert error={error} /> }
if (!isPending) { if(nowPlaying) { return <ClientNowPlaying data={data}/> } return <ChakraPlayer nowPlaying={nowPlaying} data={data} sot={sot} /> }}
type UsePlayerQueryOpts = { initData?: SourcePlayerJson}
export const usePlayerQuery = (componentId: number, platformId: string, opts: UsePlayerQueryOpts = {}) => { const { initData } = opts; const queryClient = useQueryClient(); useEffect(() => { if (initData !== undefined && queryClient.getQueryData(tanQueries.players.single(componentId, platformId).queryKey) === undefined) { queryClient.setQueryData(tanQueries.players.single(componentId, platformId).queryKey, initData); } }, [initData, componentId, platformId]);
const client = useSSEContext<MsSseEvent<MsSseEventPayload<SourcePlayerJson>>>(); useSSEEvent(client, "playerUpdate", (payload) => { if (payload.componentId === componentId && payload.data.platformId === platformId) { queryClient.setQueryData(tanQueries.players.single(componentId, platformId).queryKey, payload.data); } });
const { isPending, isError, data, error } = useQuery({ ...tanQueries.players.single(componentId, platformId), staleTime: Infinity, });
return { data, isPending, isError, error };}
export const PlayersContainer = (props: { data: ComponentCommonApiJson, live?: boolean, nowPlaying?: boolean, stack?: ComponentProps<typeof Stack>, container?: ComponentProps<typeof Container> }) => { const { data, nowPlaying, live, container = {}, stack = {} } = props;
const { players = {} } = data;
const playerContainers: React.JSX.Element[] = []; // const isSource = isComponentSourceApiJson(data); // const now = dayjs(); if (Object.keys(players).length > 0) { for(const [key, x] of Object.entries(players)) { // if(!isSource && 'expiration' in x) { // const expiresAt = dayjs(x.expiration as string); // if(now.isAfter(expiresAt)) { // continue; // } // } playerContainers.push( live ? <ChakraPlayerFetchable key={key} nowPlaying={nowPlaying} componentId={data.id} platformId={key} data={x} /> : (nowPlaying ? <ClientNowPlaying key={key} data={x}/> : <ChakraPlayer key={key} nowPlaying={nowPlaying} data={x} /> ) ); }; } // if(playerContainers.length > 0) { // return <Stack gap="2" {...stack}> // {playerContainers} // </Stack>; // } // return null;
return <Stack gap="2" width="100%" {...stack}> {playerContainers} </Stack>;}
const usePlayersQuery = (componentId: number, players?: ComponentCommonApiJson['players']) => { const queryClient = useQueryClient();
useEffect(() => { if(queryClient.getQueryData(tanQueries.players.list(componentId).queryKey) === undefined) { queryClient.setQueryData(tanQueries.players.list(componentId).queryKey, players ?? {}); } }, [players, componentId]);
const client = useSSEContext<MsSseEvent>(); useSSEAnyEvent(client, (payload) => { if('componentId' in (payload.data as object) && (payload.data as Record<string, any>).componentId === componentId) { switch(payload.type) { case 'playerUpdate': { const playerPayload = payload.data as MsSseEventPayload<SourcePlayerJson>; queryClient.setQueryData(tanQueries.players.list(componentId).queryKey, (old: Record<string, SourcePlayerJson>) => { if(old[playerPayload.data.platformId] === undefined || 'expiration' in playerPayload.data) { const newData: Record<string, SourcePlayerJson> = {...old}; newData[playerPayload.data.platformId] = playerPayload.data; return newData; } }); } break; case 'playerDelete':{ const playerPayload = payload.data as MsSseEventPayload<{platformId: string}>; queryClient.setQueryData(tanQueries.players.list(componentId).queryKey, (old: Record<string, SourcePlayerJson>) => { if(old[playerPayload.data.platformId] !== undefined) { const newData: Record<string, SourcePlayerJson> = {...old}; delete newData[playerPayload.data.platformId]; return newData; } }); } break; } } });
const { isPending, isError, data = {}, error } = useQuery({ ...tanQueries.players.list(componentId), staleTime: Infinity, });
return {isPending, isError, data, error};}
export const PlayersContainerFetchable = (props: { data: ComponentCommonApiJson, live?: boolean, nowPlaying?: boolean, stack?: ComponentProps<typeof Stack>, container?: ComponentProps<typeof Container> }) => { const { data: initData, nowPlaying, live = true, container = {}, stack = {} } = props;
const { isPending, isError, data = {}, error } = usePlayersQuery(initData.id, initData.players);
const mergedData = useMemo(() => ({...initData, players: data}),[initData,data]);
return <PlayersContainer nowPlaying={nowPlaying} data={mergedData} live container={container} stack={stack}/>}