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.
13 kB · 323 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323import React, { useCallback, useState, type ComponentProps, type PropsWithChildren } from 'react';import { EmptyState, DataList, HStack, Tabs, Box, Badge, Stack, Text, Separator, IconButton, Container, Float, Link, Span, Menu, Group, Portal, type MenuItemProps, type MenuSelectionDetails } from "@chakra-ui/react"import { LuCode, LuText } from "react-icons/lu"import type { JsonPlayObject, PlayObjectMinimal } from '../../../core/Atomic.js';import { shortTodayAwareFormat, timeToHumanTimestamp } from '../../../core/TimeUtils.js';import dayjs from 'dayjs';import { ChakraCodeBlock } from '../CodeBlock.js';import { TextMuted } from '../TextMuted.js';import { formatNumber } from '../../../core/DataUtils.js';import { Muted } from '../Typography.js';import { ArtistCreditTags } from '../ArtistCreditDisplay.js';import { MSErrorBoundary } from '../ErrorBoundary.js';import { EditIconRaw, EllipsisButton, EyeClosedIcon, EyeIcon, hasMusicServiceIcon } from '../icons/ChakraIcons.js';import type { IconType } from 'react-icons/lib';import { capitalize, capitalizeWords } from '../../../core/StringUtils.js';import { getMusicServiceIconTooltip, MusicServiceIndicators } from '../musicServices/MusicServiceIndicators.js';
const EmptyPlayData = () => { return ( <EmptyState.Root size="sm"> <EmptyState.Content> <EmptyState.Indicator /> <EmptyState.Description> No Play object was provided. </EmptyState.Description> </EmptyState.Content> </EmptyState.Root> );}
export type DisplayDates = false | 'all' | 'played' | 'seen';
export interface PlayInfoProps { play?: JsonPlayObject | PlayObjectMinimal<string> final?: JsonPlayObject showCodeToggle?: boolean showCompare?: boolean compareDefault?: 'Initial' | 'Final' dates?: false | 'all' | 'played' | 'seen' editable?: boolean onEditClick?: () => void codeContent?: React.JSX.Element}
const primaryActionProps: ComponentProps<typeof EllipsisButton> = { //margin: "1px", variant: "outline", size: 'xs'}
const menuItem = (Icon: IconType, value: string, name?: string) => (props: Pick<MenuItemProps, 'disabled'> = {}) => (<Menu.Item key={value} value={value} {...props}><Icon /><Box flex="1">{name ?? capitalize(value)}</Box></Menu.Item>)
const MenuTextMode = menuItem(LuText, 'code', 'Show Text');const MenuCodeMode = menuItem(LuCode, 'code', 'Show Code');
export const PlayData = (props?: PlayInfoProps) => { const { play, final, codeContent, showCodeToggle = true, showCompare = true, compareDefault = 'Initial', dates = 'all', editable = false, onEditClick } = props ?? {};
const [codeMode, setCodeMode] = useState(false);
const menuCb = useCallback((select: MenuSelectionDetails) => { if(select.value === 'code') { setCodeMode((old) => !old); } }, [setCodeMode])
if (play === undefined) { return <EmptyPlayData /> } let primary: React.JSX.Element | null = null;
const comparable = showCompare && final !== undefined;
const menuItems: React.JSX.Element[] = [ ];
if(editable && onEditClick !== undefined) { primary = ( <IconButton variant="outline" size="xs" {...primaryActionProps} onClick={() => onEditClick()}> <EditIconRaw/> </IconButton> ); if(showCodeToggle) { menuItems.push(codeMode ? <MenuTextMode/> : <MenuCodeMode/>); } } else if (showCodeToggle) { primary = ( <IconButton hideBelow="sm" variant="outline" size="xs" {...primaryActionProps} onClick={() => setCodeMode(!codeMode)}> {codeMode ? <LuText /> : <LuCode />} </IconButton> ); }
let content: React.JSX.Element;
if (!comparable) { content = codeMode ? (codeContent ?? <ChakraCodeBlock code={play} />) : <PlayDataDataList play={play} dates={dates}/>; } else { content = ( <Tabs.Root size="sm" variant="outline" defaultValue={compareDefault}> <Tabs.List> <Tabs.Trigger value="Initial">Initial</Tabs.Trigger> <Tabs.Trigger value="Final">Final</Tabs.Trigger> </Tabs.List> <Tabs.Content value="Initial"> {codeMode ? (codeContent ?? <ChakraCodeBlock code={play} />) : <PlayDataDataList play={play} dates={dates} />} </Tabs.Content> <Tabs.Content value="Final"> {codeMode ? (codeContent ?? <ChakraCodeBlock code={final} />) : <PlayDataDataList play={final} dates={dates} />} </Tabs.Content> </Tabs.Root> ) }
return ( <Box position="relative"> <MSErrorBoundary> <Float placement="top-end" offsetX="6" offsetY="2" zIndex={100}> <HStack> <Menu.Root positioning={{ placement: "bottom-end" }} onSelect={menuCb}> <Group attached> {primary} <Menu.Trigger asChild> <EllipsisButton {...primaryActionProps} /> </Menu.Trigger> </Group> <Portal> <Menu.Positioner> <Menu.Content> {menuItems} </Menu.Content> </Menu.Positioner> </Portal> </Menu.Root> </HStack> </Float> {content} </MSErrorBoundary> </Box> );}
const SourceMaybeLink = (props: PropsWithChildren<{link?: string}>) => { if(props.link === undefined) { return props.children; } return <Link key="weblink" variant="underline" target="_blank" href={props.link}>{props.children}</Link>}
export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDates, showMbid?: boolean }) => {
const { play, dates, showMbid = false } = props;
let albumArtistElm: React.JSX.Element | undefined;
if (play.data.albumArtists !== undefined && play.data.albumArtists.length > 0) { albumArtistElm = ( <DataList.Item> <DataList.ItemLabel>Album Artists</DataList.ItemLabel> <DataList.ItemValue alignItems="flex-start"> <ArtistCreditTags data={play.data.albumArtists} showMbid={showMbid} /> </DataList.ItemValue> </DataList.Item> ); }
const { data: { track, artists = [], listenedFor, duration, repeat, } = {}, meta: { url: { web: webUrl, origin: originUrl } = {}, source, musicService } = {} } = play;
let sourceElm: React.JSX.Element | undefined = undefined; if(musicService !== undefined && musicService !== source && source !== undefined) { sourceElm = <Span><SourceMaybeLink link={webUrl ?? originUrl}>{hasMusicServiceIcon(musicService) ? getMusicServiceIconTooltip(musicService)({size: 'md'}) : capitalizeWords(musicService)}</SourceMaybeLink> via {capitalizeWords(source)}</Span> } else if(source !== undefined) { sourceElm = <SourceMaybeLink link={webUrl ?? originUrl}>{hasMusicServiceIcon(source) ? getMusicServiceIconTooltip(source)({size: 'md'}) : capitalizeWords(source)}</SourceMaybeLink> }
const titleElm = <HStack><Span>{track?.name}</Span><MusicServiceIndicators services={track?.metadata ?? []} showId={showMbid} link/></HStack>
return ( <DataList.Root className='playDataGrid'> <DataList.Item> <DataList.ItemLabel>Title</DataList.ItemLabel> <DataList.ItemValue alignItems="flex-start">{titleElm}</DataList.ItemValue> </DataList.Item> <DataList.Item> <DataList.ItemLabel>Artists</DataList.ItemLabel> <DataList.ItemValue> {artists.length === 0 ? <Text color="fg.muted">(No Artists)</Text> : <ArtistCreditTags data={artists} showMbid={showMbid} />} </DataList.ItemValue> </DataList.Item> {albumArtistElm} <DataList.Item > <DataList.ItemLabel alignItems="flex-start">Album</DataList.ItemLabel> <DataList.ItemValue alignItems="flex-start"> <HStack> {play.data.album?.name} <MusicServiceIndicators services={play.data.album?.metadata ?? []} showId={showMbid} link/> </HStack> </DataList.ItemValue> </DataList.Item> <PlayDatesStack play={play} dates={dates} /> <DataList.Item> <DataList.ItemLabel>Duration</DataList.ItemLabel> <DataList.ItemValue> <Stack gap="1"> <Text textStyle="xs">Track Length: {duration === undefined ? 'N/A' : timeToHumanTimestamp(dayjs.duration(duration, 's'))}</Text> {listenedFor !== undefined ? <Muted textStyle="xs">Listened For: {timeToHumanTimestamp(dayjs.duration(listenedFor, 's'))}{duration !== undefined && duration > 0 ? ` (${formatNumber((listenedFor / duration) * 100)}%)` : null}</Muted> : null} </Stack> </DataList.ItemValue> </DataList.Item> {sourceElm !== undefined ? (<DataList.Item> <DataList.ItemLabel>Source</DataList.ItemLabel> <DataList.ItemValue alignItems="flex-start">{sourceElm}</DataList.ItemValue> </DataList.Item>) : undefined} </DataList.Root> )}
export const PlayInfoContainer = (props?: PlayInfoProps) => { return <Container maxWidth="lg"><PlayData {...props} /></Container>}
export const PlayDatesStack = (props: { play: JsonPlayObject, dates: DisplayDates }) => { const { play, play: { data: { repeat = false, } = {} } = {}, dates } = props;
let datesItem: React.JSX.Element | null; if (dates === false) { datesItem = null; } else { const dateElements = []; if (dates.includes('played') || dates.includes('all')) { dateElements.push((<HStack><Text textStyle="xs" key="playDate">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</Text>{repeat ? <Badge>Repeat</Badge> : null}</HStack>)); if (play.data.playDateCompleted !== undefined) { dateElements.push((<TextMuted key="playDateCompleted">{`Played Until ${shortTodayAwareFormat(dayjs(play.data.playDateCompleted))}`}</TextMuted>)); } } if (dates.includes('seen') || dates.includes('all')) { dateElements.push((<TextMuted key="seen">{`Seen ${shortTodayAwareFormat(dayjs(play.meta.seenAt))}`}</TextMuted>)); } datesItem = ( <DataList.Item> <DataList.ItemLabel>Dates</DataList.ItemLabel> <DataList.ItemValue> <Stack gap="1"> {dateElements} </Stack> </DataList.ItemValue> </DataList.Item> ) }
return datesItem;}
export const PlayDatesFooter = (props: { play: JsonPlayObject, dates: DisplayDates }) => {
const { play, dates } = props;
let dateElm: React.JSX.Element | null = null;
if (dates !== false) { let playDate: React.JSX.Element | undefined, seenDate: React.JSX.Element | undefined; if (play.data.playDate !== undefined && ['all', 'played'].includes(dates)) { playDate = <Text textStyle="xs" color="fg.muted">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</Text> } // TODO implement seenAt for play data if (play.meta.seenAt !== undefined && ['all', 'seen'].includes(dates)) { seenDate = <Text textStyle="xs" color="fg.muted">{`Seen ${shortTodayAwareFormat(dayjs(play.meta.seenAt))}`}</Text> } if (playDate !== undefined && seenDate !== undefined) { dateElm = <HStack gap="1">{playDate}<Separator orientation="vertical" height="4" />{seenDate}</HStack> } else if (playDate !== undefined) { dateElm = playDate; } else if (seenDate !== undefined) { dateElm = seenDate; } } return dateElm;}