[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.

virtualization is messy and plain accordian works better

FoxxMD (Mar 24, 2026, 6:56 PM UTC) 7605cd3f 2735c283

+305 -159
+4 -4
package-lock.json
··· 101 101 "react-icons": "^5.6.0", 102 102 "react-redux": "^8.1.2", 103 103 "react-router-dom": "^6.15.0", 104 - "react-virtuoso": "^4.17.0", 104 + "react-virtuoso": "^4.18.3", 105 105 "round-robin-js": "^3.0.10", 106 106 "sass": "^1.93.3", 107 107 "serialize-error": "^13.0.1", ··· 16975 16975 } 16976 16976 }, 16977 16977 "node_modules/react-virtuoso": { 16978 - "version": "4.17.0", 16979 - "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.17.0.tgz", 16980 - "integrity": "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w==", 16978 + "version": "4.18.3", 16979 + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.3.tgz", 16980 + "integrity": "sha512-fLz/peHAx4Eu0DLHurFEEI7Y6n5CqEoxBh04rgJM9yMuOJah2a9zWg/MUOmZLcp7zuWYorXq5+5bf3IRgkNvWg==", 16981 16981 "license": "MIT", 16982 16982 "peerDependencies": { 16983 16983 "react": ">=16 || >=17 || >= 18 || >= 19",
+1 -1
package.json
··· 139 139 "react-icons": "^5.6.0", 140 140 "react-redux": "^8.1.2", 141 141 "react-router-dom": "^6.15.0", 142 - "react-virtuoso": "^4.17.0", 142 + "react-virtuoso": "^4.18.3", 143 143 "round-robin-js": "^3.0.10", 144 144 "sass": "^1.93.3", 145 145 "serialize-error": "^13.0.1",
+8 -8
src/client/components/playActivity/PlayList.scss
··· 1 - [data-testid="virtuoso-item-list"] div[data-index]:first-child div.chakra-collapsible__root { 2 - border-top-left-radius: 0.25rem; 3 - border-top-right-radius: 0.25rem; 4 - } 1 + // [data-testid="virtuoso-item-list"] div[data-index]:first-child div.chakra-collapsible__root { 2 + // border-top-left-radius: 0.25rem; 3 + // border-top-right-radius: 0.25rem; 4 + // } 5 5 6 - [data-testid="virtuoso-item-list"] div[data-index]:last-child div.chakra-collapsible__root { 7 - border-bottom-left-radius: 0.25rem; 8 - border-bottom-right-radius: 0.25rem; 9 - } 6 + // [data-testid="virtuoso-item-list"] div[data-index]:last-child div.chakra-collapsible__root { 7 + // border-bottom-left-radius: 0.25rem; 8 + // border-bottom-right-radius: 0.25rem; 9 + // }
+291 -145
src/client/components/playActivity/PlayList.tsx
··· 4 4 import { TextMuted } from '../TextMuted.js'; 5 5 import { LuChevronRight } from "react-icons/lu" 6 6 import { capitalize } from '../../../core/StringUtils.js'; 7 - import { ComponentProps, useMemo } from "react" 7 + import { ComponentProps, useMemo, forwardRef, Fragment } from "react" 8 8 import dayjs, { Dayjs } from 'dayjs'; 9 9 import doy from 'dayjs/plugin/dayOfYear.js'; 10 10 import { VscDebugRestart } from "react-icons/vsc"; 11 - import { PlayData, PlayInfoContainer } from '../PlayData.js'; 12 11 import { GroupedVirtuoso } from 'react-virtuoso' 13 12 import { ActivityDetails } from '../ActivityDetail.js'; 14 13 import { sortByNewestPlayDate } from '../../../core/PlayUtils.js'; ··· 19 18 export interface ActivityLogProps { 20 19 data: PlayActivity[] 21 20 sortBy?: 'played' | 'seen' 22 - virtual?: boolean 21 + render?: 'virtCollapse' | 'virtAccordian' | 'accordian' 23 22 } 24 23 25 24 interface GroupInfo { ··· 27 26 date: Dayjs 28 27 } 29 28 29 + interface GroupData { 30 + plays: PlayActivity[] 31 + date: Dayjs 32 + } 33 + 34 + const generateGroupInfo = (data: PlayActivity[]): GroupInfo[] => { 35 + 36 + const groupsReduced = data.reduce((acc: { groups: GroupInfo[], active?: GroupInfo }, curr, index) => { 37 + const date = dayjs(curr.play.data.playDate); 38 + if (acc.active === undefined) { 39 + return { ...acc, active: { count: 1, date } }; 40 + } 41 + if (acc.active.date.dayOfYear() !== date.dayOfYear()) { 42 + return { groups: [...acc.groups, acc.active], active: { count: 1, date } } 43 + } 44 + 45 + return { groups: acc.groups, active: { ...acc.active, count: acc.active.count + 1 } }; 46 + }, { groups: [] }); 47 + 48 + return groupsReduced.groups.concat(groupsReduced.active); 49 + } 50 + 51 + const generateGroupPlays = (data: PlayActivity[]): GroupData[] => { 52 + 53 + const groupsReduced = data.reduce((acc: { groups: GroupData[], active?: GroupData }, curr, index) => { 54 + const date = dayjs(curr.play.data.playDate); 55 + if (acc.active === undefined) { 56 + return { ...acc, active: { plays: [curr], date } }; 57 + } 58 + if (acc.active.date.dayOfYear() !== date.dayOfYear()) { 59 + return { groups: [...acc.groups, acc.active], active: { plays: [curr], date } } 60 + } 61 + 62 + return { groups: acc.groups, active: { ...acc.active, plays: acc.active.plays.concat(curr) } }; 63 + }, { groups: [] }); 64 + 65 + return groupsReduced.groups.concat(groupsReduced.active); 66 + } 67 + 30 68 export const PlayList = (props: ActivityLogProps) => { 31 69 32 70 const { 33 71 data = [], 34 72 sortBy = 'played', 35 - virtual = false, 73 + render = 'accordian' 36 74 } = props; 37 75 38 - if(virtual) { 39 - 40 - 41 - 42 76 const sorted = useMemo(() => props.data.toSorted((a, b) => sortByNewestPlayDate(a.play, b.play)), [data, sortBy]); 43 - const groupsReduced = useMemo(() => { 44 - return sorted.reduce((acc: { groups: GroupInfo[], active?: GroupInfo }, curr, index) => { 45 - const date = dayjs(curr.play.data.playDate); 46 - if (acc.active === undefined) { 47 - return { ...acc, active: { count: 1, date } }; 48 - } 49 - if (acc.active.date.dayOfYear() !== date.dayOfYear()) { 50 - return { groups: [...acc.groups, acc.active], active: { count: 1, date } } 51 - } 52 77 53 - return { groups: acc.groups, active: { ...acc.active, count: acc.active.count + 1 } }; 54 - }, { groups: [] }); 55 - }, [sorted, sortBy]); 78 + if (render === 'accordian') { 79 + return <PlainAccordian data={sorted} /> 80 + } 81 + if (render === 'virtCollapse') { 82 + return <VirtualizedCollapse data={sorted} /> 83 + } 84 + if (render === 'virtAccordian') { 85 + return <VirtualizedAccordian data={sorted} />; 86 + } 87 + } 56 88 57 - const allGroups = groupsReduced.groups.concat(groupsReduced.active); 58 - 89 + const VirtualizedCollapse = (props: { data: PlayActivity[] }) => { 90 + const { 91 + data, 92 + } = props; 93 + const groups = useMemo(() => generateGroupInfo(data), [data]); 59 94 return ( 60 - <GroupedVirtuoso 61 - style={{ height: '700px' }} 62 - fixedItemHeight={80} 63 - fixedGroupHeight={50} 64 - groupCounts={allGroups.map(x => x.count)} 65 - groupContent={(index) => { 66 - const gData = allGroups[index]; 67 - let headerText: string; 68 - if (gData.date.isToday()) { 69 - headerText = 'Today'; 70 - } else { 71 - headerText = gData.date.format('MMM DD'); 72 - if (gData.date.year() !== dayjs().year()) { 73 - headerText += `, ${gData.date.year()}`; 74 - } 95 + <GroupedVirtuoso 96 + style={{ height: '700px' }} 97 + fixedItemHeight={80} 98 + fixedGroupHeight={50} 99 + // components={{ 100 + // List: (args) => { 101 + // return <div data-testid={args["data-testid"]} style={args.style}>{args.children}</div>} 102 + // }} 103 + groupCounts={groups.map(x => x.count)} 104 + groupContent={(index) => { 105 + const gData = groups[index]; 106 + let headerText: string; 107 + if (gData.date.isToday()) { 108 + headerText = 'Today'; 109 + } else { 110 + headerText = gData.date.format('MMM DD'); 111 + if (gData.date.year() !== dayjs().year()) { 112 + headerText += `, ${gData.date.year()}`; 75 113 } 76 - return ( 77 - <Box paddingY="2"> 78 - <Flex direction="row" justify="space-between"> 114 + } 115 + return ( 116 + <Box paddingY="2"> 117 + <Flex direction="row" justify="space-between"> 79 118 80 - <Text fontWeight="semibold">{headerText}</Text> 119 + <Text fontWeight="semibold">{headerText}</Text> 81 120 82 - <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 83 - <VscDebugRestart /> 84 - </IconButton> 85 - </Flex> 86 - <Separator orientation="horizontal" height="4" /> 87 - </Box> 88 - ) 89 - }} 90 - itemContent={(index, groupIndex) => { 91 - const activity = sorted[index]; 92 - const { play } = activity; 93 - return ( 94 - <Collapsible.Root key={index} 95 - 121 + <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 122 + <VscDebugRestart /> 123 + </IconButton> 124 + </Flex> 125 + <Separator orientation="horizontal" height="4" /> 126 + </Box> 127 + ) 128 + }} 129 + itemContent={(index, groupIndex) => { 130 + const activity = data[index]; 131 + const { play } = activity; 132 + return ( 133 + <Collapsible.Root key={index} 134 + 96 135 lazyMount 97 136 _open={{ 98 137 background: "var(--chakra-colors-bg-subtle)" 99 138 }} 100 139 style={{ 101 - borderColor: "var(--chakra-colors-border)", 102 - borderWidth: '1px', 103 - }} 104 - > 105 - <Flex justify="space-between"> 106 - <Collapsible.Trigger 107 - userSelect="text" 108 - 140 + borderColor: "var(--chakra-colors-border)", 141 + borderWidth: '1px', 142 + }} 143 + > 144 + <Flex justify="space-between"> 145 + <Collapsible.Trigger 146 + userSelect="text" 147 + 109 148 paddingY="3" 110 149 display="flex" 111 150 gap="2" 112 151 alignItems="center" 113 - truncate cursor="pointer" 114 - style={{ 152 + truncate cursor="pointer" 153 + style={{ 115 154 paddingBlock: "var(--chakra-spacing-2)", 116 155 paddingInline: "var(--chakra-spacing-4)" 117 156 }} 157 + > 158 + <Collapsible.Indicator 159 + transition="transform 0.2s" 160 + _open={{ transform: "rotate(90deg)" }} 118 161 > 119 - <Collapsible.Indicator 120 - transition="transform 0.2s" 121 - _open={{ transform: "rotate(90deg)" }} 122 - > 123 - <LuChevronRight /> 124 - </Collapsible.Indicator> 125 - <Stack gap="1" truncate alignItems="flex-start"> 126 - <Span>{play.data.track}</Span> 127 - <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 128 - <HStack gap="1"> 129 - <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 130 - <TextMuted>{play.meta?.source}</TextMuted> 131 - </HStack> 132 - </Stack> 133 - </Collapsible.Trigger> 134 - <Stack style={{ 135 - paddingBlock: "var(--chakra-spacing-2)", 136 - paddingInline: "var(--chakra-spacing-4)" 137 - }} justify="flex-start" alignItems="flex-end"> 138 - <StatusBadge maxWidth="fit-content" data={activity} /> 139 - {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 140 - <VscDebugRestart /> 141 - </IconButton> : null} 162 + <LuChevronRight /> 163 + </Collapsible.Indicator> 164 + <Stack gap="1" truncate alignItems="flex-start"> 165 + <Span>{play.data.track}</Span> 166 + <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 167 + <HStack gap="1"> 168 + <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 169 + <TextMuted>{play.meta?.source}</TextMuted> 170 + </HStack> 142 171 </Stack> 172 + </Collapsible.Trigger> 173 + <Stack style={{ 174 + paddingBlock: "var(--chakra-spacing-2)", 175 + paddingInline: "var(--chakra-spacing-4)" 176 + }} justify="flex-start" alignItems="flex-end"> 177 + <StatusBadge maxWidth="fit-content" data={activity} /> 178 + {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 179 + <VscDebugRestart /> 180 + </IconButton> : null} 181 + </Stack> 143 182 144 - </Flex> 145 - <Collapsible.Content borderTopColor="gray.border" 146 - style={{ 147 - paddingBlock: "var(--chakra-spacing-4)", 148 - paddingInline: "var(--chakra-spacing-4)" 149 - }}> 150 - <ActivityDetails activity={activity} /> 151 - </Collapsible.Content> 152 - </Collapsible.Root> 153 - ) 154 - }} 155 - /> 183 + </Flex> 184 + <Collapsible.Content borderTopColor="gray.border" 185 + style={{ 186 + paddingBlock: "var(--chakra-spacing-4)", 187 + paddingInline: "var(--chakra-spacing-4)" 188 + }}> 189 + <ActivityDetails activity={activity} /> 190 + </Collapsible.Content> 191 + </Collapsible.Root> 192 + ) 193 + }} 194 + /> 156 195 ); 157 - 158 196 } 159 197 198 + const VirtualizedAccordian = (props: { data: PlayActivity[] }) => { 199 + const { 200 + data, 201 + } = props; 202 + const groups = useMemo(() => generateGroupInfo(data), [data]); 160 203 return ( 161 - <Stack gap="2"> 162 - <Box> 163 - <Flex direction="row" justify="space-between"> 204 + <GroupedVirtuoso 205 + style={{ height: '700px' }} 206 + fixedItemHeight={80} 207 + fixedGroupHeight={50} 208 + components={{ 209 + List: forwardRef((args, ref) => { 210 + // @ts-ignore 211 + if (args.children.length === 1 && args.children[0].type.name === 'Group') { 212 + // @ts-ignore 213 + return <div ref={ref} data-testid={args["data-testid"]} style={args.style}>{args.children}</div> 214 + } 215 + // @ts-ignore 216 + return <Accordion.Root ref={ref} data-testid={args["data-testid"]} style={args.style} lazyMount variant="enclosed" collapsible multiple>{args.children}</Accordion.Root> 217 + }), 218 + Group: (args) => { 219 + return <div data-testid={args["data-testid"]} style={args.style}>{args.children}</div> 220 + } 221 + }} 222 + groupCounts={groups.map(x => x.count)} 223 + groupContent={(index) => { 224 + const gData = groups[index]; 225 + let headerText: string; 226 + if (gData.date.isToday()) { 227 + headerText = 'Today'; 228 + } else { 229 + headerText = gData.date.format('MMM DD'); 230 + if (gData.date.year() !== dayjs().year()) { 231 + headerText += `, ${gData.date.year()}`; 232 + } 233 + } 234 + return ( 235 + <Box paddingY="2"> 236 + <Flex direction="row" justify="space-between"> 164 237 165 - <Text fontWeight="semibold">Today</Text> 238 + <Text fontWeight="semibold">{headerText}</Text> 166 239 167 - <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 168 - <VscDebugRestart /> 169 - </IconButton> 170 - </Flex> 171 - <Separator orientation="horizontal" height="4" /> 172 - </Box> 173 - <Accordion.Root variant="enclosed" collapsible multiple> 174 - {props.data.map((activity, index) => { 175 - const { play } = activity; 176 - return ( 177 - <Accordion.Item key={index} value={index.toString()}> 178 - <Flex justify="space-between"> 179 - <Accordion.ItemTrigger truncate cursor="pointer"> 180 - <Accordion.ItemIndicator /> 181 - <Stack gap="1" truncate> 182 - <Span>{play.data.track}</Span> 183 - <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 184 - <HStack gap="1"> 185 - <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 186 - <TextMuted>{play.meta?.source}</TextMuted> 187 - </HStack> 188 - </Stack> 189 - </Accordion.ItemTrigger> 190 - <Stack style={{ 191 - paddingBlock: "var(--accordion-padding-y)", 192 - paddingInline: "var(--accordion-padding-x)" 193 - }} justify="flex-start" alignItems="flex-end"> 194 - <StatusBadge maxWidth="fit-content" data={activity} /> 195 - {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 196 - <VscDebugRestart /> 197 - </IconButton> : null} 240 + <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 241 + <VscDebugRestart /> 242 + </IconButton> 243 + </Flex> 244 + <Separator orientation="horizontal" height="4" /> 245 + </Box> 246 + ) 247 + }} 248 + itemContent={(index, groupIndex) => { 249 + const activity = data[index]; 250 + const { play } = activity; 251 + return ( 252 + <Accordion.Item key={index} value={index.toString()}> 253 + <Flex justify="space-between"> 254 + <Accordion.ItemTrigger truncate cursor="pointer"> 255 + <Accordion.ItemIndicator /> 256 + <Stack gap="1" truncate> 257 + <Span>{play.data.track}</Span> 258 + <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 259 + <HStack gap="1"> 260 + <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 261 + <TextMuted>{play.meta?.source}</TextMuted> 262 + </HStack> 198 263 </Stack> 264 + </Accordion.ItemTrigger> 265 + <Stack style={{ 266 + paddingBlock: "var(--accordion-padding-y)", 267 + paddingInline: "var(--accordion-padding-x)" 268 + }} justify="flex-start" alignItems="flex-end"> 269 + <StatusBadge maxWidth="fit-content" data={activity} /> 270 + {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 271 + <VscDebugRestart /> 272 + </IconButton> : null} 273 + </Stack> 274 + 275 + </Flex> 276 + <Accordion.ItemContent> 277 + <Accordion.ItemBody borderTopColor="gray.border" > 278 + <ActivityDetails activity={activity} /> 279 + </Accordion.ItemBody> 280 + </Accordion.ItemContent> 281 + </Accordion.Item> 282 + ) 283 + }} 284 + /> 285 + ); 286 + } 287 + 288 + const PlainAccordian = (props: { data: PlayActivity[] }) => { 289 + const { data = [] } = props; 290 + const groups = generateGroupPlays(data); 291 + return ( 292 + <Stack gap="2"> 293 + {groups.map((g) => { 294 + let headerText: string; 295 + if (g.date.isToday()) { 296 + headerText = 'Today'; 297 + } else { 298 + headerText = g.date.format('MMM DD'); 299 + if (g.date.year() !== dayjs().year()) { 300 + headerText += `, ${g.date.year()}`; 301 + } 302 + } 303 + return ( 304 + <Fragment> 305 + <Box> 306 + <Flex direction="row" justify="space-between"> 307 + 308 + <Text fontWeight="semibold">{headerText}</Text> 199 309 310 + <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 311 + <VscDebugRestart /> 312 + </IconButton> 200 313 </Flex> 201 - <Accordion.ItemContent> 202 - <Accordion.ItemBody borderTopColor="gray.border" > 203 - <ActivityDetails activity={activity}/> 204 - </Accordion.ItemBody> 205 - </Accordion.ItemContent> 206 - </Accordion.Item> 207 - ) 208 - })} 209 - </Accordion.Root> 314 + <Separator orientation="horizontal" height="4" /> 315 + </Box> 316 + <Accordion.Root variant="enclosed" collapsible multiple lazyMount> 317 + {g.plays.map((activity, index) => { 318 + const { play } = activity; 319 + return ( 320 + <Accordion.Item key={index} value={index.toString()}> 321 + <Flex justify="space-between"> 322 + <Accordion.ItemTrigger truncate cursor="pointer"> 323 + <Accordion.ItemIndicator /> 324 + <Stack gap="1" truncate> 325 + <Span>{play.data.track}</Span> 326 + <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 327 + <HStack gap="1"> 328 + <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 329 + <TextMuted>{play.meta?.source}</TextMuted> 330 + </HStack> 331 + </Stack> 332 + </Accordion.ItemTrigger> 333 + <Stack style={{ 334 + paddingBlock: "var(--accordion-padding-y)", 335 + paddingInline: "var(--accordion-padding-x)" 336 + }} justify="flex-start" alignItems="flex-end"> 337 + <StatusBadge maxWidth="fit-content" data={activity} /> 338 + {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 339 + <VscDebugRestart /> 340 + </IconButton> : null} 341 + </Stack> 342 + 343 + </Flex> 344 + <Accordion.ItemContent> 345 + <Accordion.ItemBody borderTopColor="gray.border" > 346 + <ActivityDetails activity={activity} /> 347 + </Accordion.ItemBody> 348 + </Accordion.ItemContent> 349 + </Accordion.Item> 350 + ) 351 + })} 352 + </Accordion.Root> 353 + </Fragment> 354 + ) 355 + })} 210 356 </Stack> 211 357 ); 212 358 }
+1 -1
src/core/tests/utils/fixtures.ts
··· 296 296 return; 297 297 } 298 298 somethingModified = true; 299 - if(ctx.key === 'brainz' && Object.keys(x).length === 0) { 299 + if(ctx.key === 'brainz' && Object.keys(x ?? {}).length === 0) { 300 300 ctx.update(generateBrainz(play, {include: ['album', 'artist', 'track']}), true); 301 301 } else if (ctx.parent !== undefined && ctx.parent.key === 'brainz') { 302 302 if (Array.isArray(x)) {