[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): Improve play data

FoxxMD (Mar 19, 2026, 8:23 PM UTC) 943bdc3e 44710048

+122 -95
+122 -95
src/client/components/PlayData.tsx
··· 1 1 import React, { Fragment, useMemo, useState } from 'react'; 2 - import { EmptyState, DataList, HStack, Tag, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid } from "@chakra-ui/react" 2 + import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid, Float } from "@chakra-ui/react" 3 3 import { LuCode, LuText } from "react-icons/lu" 4 4 import { JsonPlayObject } from '../../core/Atomic.js'; 5 5 import { shortTodayAwareFormat } from '../../core/TimeUtils.js'; 6 6 import dayjs from 'dayjs'; 7 7 import { ChakraCodeBlock } from './CodeBlock.js'; 8 - import { safeStringify } from '../../core/StringUtils.js'; 9 8 import { TextMuted } from './TextMuted.js'; 10 9 11 10 const EmptyPlayData = () => { ··· 21 20 ); 22 21 } 23 22 23 + export type DisplayDates = false | 'all' | 'played' | 'seen'; 24 + 24 25 export interface PlayInfoProps { 25 26 play?: JsonPlayObject 26 27 final?: JsonPlayObject 27 28 showCodeToggle?: boolean 28 29 showCompare?: boolean 29 30 compareDefault?: 'Initial' | 'Final' 30 - datesFooter?: false | 'all' | 'played' | 'seen' 31 31 dates?: false | 'all' | 'played' | 'seen' 32 32 } 33 33 ··· 38 38 showCodeToggle = true, 39 39 showCompare = true, 40 40 compareDefault = 'Initial', 41 - datesFooter = false, 42 41 dates = 'all' 43 42 } = props ?? {}; 44 43 ··· 46 45 return <EmptyPlayData /> 47 46 } 48 47 49 - const [compareVal, setCompareVal] = useState(compareDefault) 50 48 const [codeMode, setCodeMode] = useState(false); 51 49 52 - const displayedPlay = compareVal === 'Initial' ? play : final; 50 + let code: JSX.Element | null = null; 53 51 54 - let comparer: JSX.Element | null = null, 55 - copy: JSX.Element | null = null; 52 + const comparable = showCompare && final !== undefined; 56 53 57 - if (showCompare && final !== undefined) { 58 - comparer = ( 59 - <SegmentGroup.Root size="xs" value={compareVal} onValueChange={(e) => setCompareVal(e.value as 'Initial' | 'Final')}> 60 - <SegmentGroup.Indicator /> 61 - <SegmentGroup.Items items={["Initial", "Final"]} /> 62 - </SegmentGroup.Root> 63 - ); 64 - } 65 54 if (showCodeToggle) { 66 - copy = ( 55 + code = ( 67 56 <IconButton variant="outline" size="xs" onClick={() => setCodeMode(!codeMode)}> 68 57 {codeMode ? <LuText /> : <LuCode />} 69 58 </IconButton> 70 59 ); 71 60 } 72 61 73 - const datesFooterContent = useMemo(() => { 74 - let dateElm: JSX.Element; 75 - if (datesFooter !== false) { 76 - let playDate: JSX.Element, 77 - seenDate: JSX.Element; 78 - if (play.data.playDate !== undefined && ['all', 'played'].includes(datesFooter)) { 79 - playDate = <Text textStyle="xs" color="fg.muted">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</Text> 80 - } 81 - // TODO implement seenAt for play data 82 - if (play.data.playDateCompleted !== undefined && ['all', 'seen'].includes(datesFooter)) { 83 - seenDate = <Text textStyle="xs" color="fg.muted">{`Seen ${shortTodayAwareFormat(dayjs(play.data.playDateCompleted))}`}</Text> 84 - } 85 - if (playDate !== undefined && seenDate !== undefined) { 86 - dateElm = <HStack gap="1">{playDate}<Separator orientation="vertical" height="4" />{seenDate}</HStack> 87 - } else if (playDate !== undefined) { 88 - dateElm = playDate; 89 - } else if (seenDate !== undefined) { 90 - dateElm = seenDate; 91 - } 92 - } 93 - return dateElm; 94 - }, [play, datesFooter]); 62 + if (!comparable) { 63 + return (<Box position="relative"> 64 + <Float placement="top-end" offset="3" hideBelow="sm" zIndex={100}>{code}</Float> 65 + {codeMode ? <ChakraCodeBlock code={play} /> : <PlayDataDataList play={play} dates={dates} />} 66 + </Box>); 67 + } 68 + 69 + return ( 70 + <Box position="relative"> 71 + <Float placement="top-end" offset="3" hideBelow="sm" zIndex={100}>{code}</Float> 72 + <Tabs.Root defaultValue={compareDefault}> 73 + <Tabs.List> 74 + <Tabs.Trigger value="Initial">Initial</Tabs.Trigger> 75 + <Tabs.Trigger value="Final">Final</Tabs.Trigger> 76 + </Tabs.List> 77 + <Tabs.Content value="Initial"> 78 + {codeMode ? <ChakraCodeBlock code={play} /> : <PlayDataDataList play={play} dates={dates} />} 79 + </Tabs.Content> 80 + <Tabs.Content value="Final"> 81 + {codeMode ? <ChakraCodeBlock code={final} /> : <PlayDataDataList play={final} dates={dates} />} 82 + </Tabs.Content> 83 + </Tabs.Root> 84 + </Box> 85 + ); 86 + } 87 + 88 + export const PlayDataDataList = (props: { play: JsonPlayObject, dates: DisplayDates }) => { 89 + 90 + const { 91 + play, 92 + dates 93 + } = props; 94 + 95 95 96 96 let albumArtistElm: JSX.Element; 97 97 98 - if (displayedPlay.data.albumArtists !== undefined && displayedPlay.data.albumArtists.length > 0) { 98 + if (play.data.albumArtists !== undefined && play.data.albumArtists.length > 0) { 99 99 albumArtistElm = ( 100 100 <DataList.Item> 101 101 <DataList.ItemLabel>Album Artists</DataList.ItemLabel> 102 102 <DataList.ItemValue> 103 - <HStack>{displayedPlay.data.albumArtists.map((x, index) => { 103 + <HStack>{play.data.albumArtists.map((x, index) => { 104 104 return ( 105 105 <Tag.Root key={index}> 106 106 <Tag.Label>{x}</Tag.Label> ··· 116 116 data: { 117 117 artists = [] 118 118 } = {} 119 - } = displayedPlay; 119 + } = play; 120 + 121 + return ( 122 + <Flex gap="2" wrap="wrap"> 123 + <Box marginEnd="auto"> 124 + <DataList.Root> 125 + <DataList.Item> 126 + <DataList.ItemLabel flexShrink="1">Title</DataList.ItemLabel> 127 + <DataList.ItemValue>{play.data.track}</DataList.ItemValue> 128 + </DataList.Item> 129 + <DataList.Item> 130 + <DataList.ItemLabel>Artists</DataList.ItemLabel> 131 + <DataList.ItemValue> 132 + {artists.length === 0 ? <Text color="fg.muted">(No Artists)</Text> : 133 + <HStack>{play.data.artists.map((x, index) => { 134 + return ( 135 + <Tag.Root key={index}> 136 + <Tag.Label>{x}</Tag.Label> 137 + </Tag.Root> 138 + ); 139 + })}</HStack>} 140 + </DataList.ItemValue> 141 + </DataList.Item> 142 + {albumArtistElm} 143 + <DataList.Item> 144 + <DataList.ItemLabel>Album</DataList.ItemLabel> 145 + <DataList.ItemValue>{play.data.album}</DataList.ItemValue> 146 + </DataList.Item> 147 + </DataList.Root> 148 + </Box> 149 + <Box> 150 + <DataList.Root> 151 + <PlayDatesStack play={play} dates={dates} /> 152 + </DataList.Root> 153 + </Box> 154 + </Flex> 155 + ) 156 + } 157 + 158 + export const PlayInfoContainer = (props?: PlayInfoProps) => { 159 + return <Container maxWidth="lg"><PlayData {...props} /></Container> 160 + } 161 + 162 + export const PlayDatesStack = (props: { play: JsonPlayObject, dates: DisplayDates }) => { 163 + const { 164 + play, 165 + dates 166 + } = props; 120 167 121 168 let datesItem: JSX.Element | null; 122 - if(dates === false) { 169 + if (dates === false) { 123 170 datesItem = null; 124 171 } else { 125 172 const dateElements = []; 126 - if(dates.includes('played') || dates.includes('all')) { 173 + if (dates.includes('played') || dates.includes('all')) { 127 174 dateElements.push((<TextMuted>{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</TextMuted>)); 128 - if(play.data.playDateCompleted !== undefined) { 175 + if (play.data.playDateCompleted !== undefined) { 129 176 dateElements.push((<TextMuted>{`Played Until ${shortTodayAwareFormat(dayjs(play.data.playDateCompleted))}`}</TextMuted>)); 130 177 } 131 178 } 132 - if(dates.includes('seen') || dates.includes('all')) { 179 + if (dates.includes('seen') || dates.includes('all')) { 133 180 dateElements.push((<TextMuted>{`Seen ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</TextMuted>)); 134 181 } 135 182 datesItem = ( ··· 144 191 ) 145 192 } 146 193 147 - return ( 148 - <Fragment> 149 - <Stack gap="3"> 150 - <Flex justify="space-between"> 151 - <Box>{comparer}</Box> <Box>{copy}</Box> 152 - </Flex> 153 - { 154 - codeMode ? <ChakraCodeBlock code={safeStringify(displayedPlay)} /> : ( 155 - <Flex gap="2" wrap="wrap"> 156 - <Box marginEnd="auto"> 157 - <DataList.Root> 158 - <DataList.Item> 159 - <DataList.ItemLabel flexShrink="1">Title</DataList.ItemLabel> 160 - <DataList.ItemValue>{displayedPlay.data.track}</DataList.ItemValue> 161 - </DataList.Item> 162 - <DataList.Item> 163 - <DataList.ItemLabel>Artists</DataList.ItemLabel> 164 - <DataList.ItemValue> 165 - {artists.length === 0 ? <Text color="fg.muted">(No Artists)</Text> : 166 - <HStack>{displayedPlay.data.artists.map((x, index) => { 167 - return ( 168 - <Tag.Root key={index}> 169 - <Tag.Label>{x}</Tag.Label> 170 - </Tag.Root> 171 - ); 172 - })}</HStack>} 173 - </DataList.ItemValue> 174 - </DataList.Item> 175 - {albumArtistElm} 176 - <DataList.Item> 177 - <DataList.ItemLabel>Album</DataList.ItemLabel> 178 - <DataList.ItemValue>{displayedPlay.data.album}</DataList.ItemValue> 179 - </DataList.Item> 180 - </DataList.Root> 181 - </Box> 182 - <Box> 183 - <DataList.Root> 184 - {datesItem} 185 - </DataList.Root> 186 - </Box> 187 - {datesFooterContent} 188 - </Flex> 189 - ) 190 - } 191 - </Stack> 192 - </Fragment> 193 - ) 194 + return datesItem; 194 195 } 195 196 196 - export const PlayInfoContainer = (props?: PlayInfoProps) => { 197 - return <Container maxWidth="lg"><PlayData {...props} /></Container> 197 + export const PlayDatesFooter = (props: { play: JsonPlayObject, dates: DisplayDates }) => { 198 + 199 + const { 200 + play, 201 + dates 202 + } = props; 203 + 204 + let dateElm: JSX.Element; 205 + 206 + if (dates !== false) { 207 + let playDate: JSX.Element, 208 + seenDate: JSX.Element; 209 + if (play.data.playDate !== undefined && ['all', 'played'].includes(dates)) { 210 + playDate = <Text textStyle="xs" color="fg.muted">{`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`}</Text> 211 + } 212 + // TODO implement seenAt for play data 213 + if (play.data.playDateCompleted !== undefined && ['all', 'seen'].includes(dates)) { 214 + seenDate = <Text textStyle="xs" color="fg.muted">{`Seen ${shortTodayAwareFormat(dayjs(play.data.playDateCompleted))}`}</Text> 215 + } 216 + if (playDate !== undefined && seenDate !== undefined) { 217 + dateElm = <HStack gap="1">{playDate}<Separator orientation="vertical" height="4" />{seenDate}</HStack> 218 + } else if (playDate !== undefined) { 219 + dateElm = playDate; 220 + } else if (seenDate !== undefined) { 221 + dateElm = seenDate; 222 + } 223 + } 224 + return dateElm; 198 225 }