[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): More activity log improvements

FoxxMD (Mar 18, 2026, 8:12 PM UTC) 559d7492 53feae0f

+75 -37
+74 -36
src/client/components/List.tsx
··· 1 - import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack } from '@chakra-ui/react'; 1 + import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton } from '@chakra-ui/react'; 2 2 import { JsonPlayObject } from '../../core/Atomic'; 3 3 import { ShortDateDisplay } from './DateDisplay'; 4 - import { truncateStringToLength } from '../../core/StringUtils'; 5 4 import { TextMuted } from './TextMuted'; 6 - 7 - const shortArtist = truncateStringToLength(20); 8 - 9 - const items = [ 10 - { value: 'a', title: 'First Item', text: 'Some value 1..' }, 11 - { value: 'b', title: 'Second Item', text: 'Some value 2...' }, 12 - { value: 'c', title: 'Third Item', text: 'Some value 3...' }, 13 - ]; 14 - 5 + import { capitalize } from '../../core/StringUtils'; 6 + import { ComponentProps } from "react" 7 + import { VscDebugRestart } from "react-icons/vsc"; 8 + export interface PlayActivity { 9 + play: JsonPlayObject 10 + status: string 11 + } 15 12 export interface ActivityLogProps { 16 - plays: JsonPlayObject[] 13 + data: PlayActivity[] 17 14 } 18 15 19 - // shortArtist(item.data.artists.join(' / ')) 20 - 21 16 export const CList = (props: ActivityLogProps) => { 22 17 return ( 23 18 <Stack gap="2"> 24 - <Text fontWeight="semibold">Today</Text> 19 + <Box> 20 + <Flex direction="row" justify="space-between"> 21 + 22 + <Text fontWeight="semibold">Today</Text> 23 + 24 + <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 25 + <VscDebugRestart /> 26 + </IconButton> 27 + </Flex> 28 + <Separator orientation="horizontal" height="4" /> 29 + </Box> 25 30 <Accordion.Root variant="enclosed" collapsible> 26 - {props.plays.map((item, index) => ( 27 - <Accordion.Item key={index} value={index.toString()}> 28 - <Box position="relative"> 29 - <Accordion.ItemTrigger> 30 - <Accordion.ItemIndicator /> 31 - <Stack gap="1"> 32 - <Span flex="1">{item.data.track}</Span> 33 - <TextMuted overflow="hidden" textOverflow="ellipsis">{item.data.artists.join(' / ')}</TextMuted> 34 - <HStack gap="1"><ShortDateDisplay date={item.data.playDate} prefix="Played"/><Separator orientation="vertical" height="4" /><TextMuted>{item.meta?.source}</TextMuted></HStack> 31 + {props.data.map((activity, index) => { 32 + const { play } = activity; 33 + return ( 34 + <Accordion.Item key={index} value={index.toString()}> 35 + <Flex justify="space-between"> 36 + <Accordion.ItemTrigger truncate cursor="pointer"> 37 + <Accordion.ItemIndicator /> 38 + <Stack gap="1" truncate> 39 + <Span>{play.data.track}</Span> 40 + <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 41 + <HStack gap="1"> 42 + <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 43 + <TextMuted>{play.meta?.source}</TextMuted> 44 + </HStack> 45 + </Stack> 46 + </Accordion.ItemTrigger> 47 + <Stack style={{ 48 + paddingBlock: "var(--accordion-padding-y)", 49 + paddingInline: "var(--accordion-padding-x)" 50 + }} justify="flex-start" alignItems="flex-end"> 51 + <StatusBadge maxWidth="fit-content" data={activity} /> 52 + {activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 53 + <VscDebugRestart /> 54 + </IconButton> : null} 35 55 </Stack> 36 - </Accordion.ItemTrigger> 37 - <AbsoluteCenter axis="vertical" insetEnd="0" padding="1em"> 38 - <Button variant="subtle" size="xs"> 39 - Retry 40 - </Button> 41 - </AbsoluteCenter> 42 - </Box> 43 - <Accordion.ItemContent> 44 - <Accordion.ItemBody borderTopColor="gray.border" borderTopWidth="1px">{item.data.track}</Accordion.ItemBody> 45 - </Accordion.ItemContent> 46 - </Accordion.Item> 47 - ))} 56 + 57 + </Flex> 58 + <Accordion.ItemContent> 59 + <Accordion.ItemBody borderTopColor="gray.border" borderTopWidth="1px">{play.data.track}</Accordion.ItemBody> 60 + </Accordion.ItemContent> 61 + </Accordion.Item> 62 + ) 63 + })} 48 64 </Accordion.Root> 49 65 </Stack> 50 66 ); 67 + } 68 + 69 + const StatusBadge = (props: ComponentProps<typeof Badge> & { data: PlayActivity }) => { 70 + 71 + const { data, ...rest } = props; 72 + 73 + let badgeColor = undefined, 74 + badgeText = capitalize(data.status); 75 + 76 + switch (data.status) { 77 + case 'queued': 78 + badgeColor = 'gray'; 79 + break; 80 + case 'scrobbled': 81 + badgeColor = 'green'; 82 + break; 83 + case 'error': 84 + badgeColor = 'red'; 85 + break; 86 + } 87 + 88 + return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{badgeText}</Badge> 51 89 }
+1 -1
src/stories/List.stories.tsx
··· 20 20 tags: ['autodocs'], 21 21 // More on argTypes: https://storybook.js.org/docs/api/argtypes 22 22 args: { 23 - plays: generateJsonPlays(2, undefined, {source: 'Spotify'}), 23 + data: (generateJsonPlays(3, undefined, {source: 'Spotify'})).map((x, index) => ({play: x, status: index === 0 ? 'queued' : index === 1 ? 'scrobbled' : index === 2 ? 'error' : 'unknown'})), 24 24 }, 25 25 decorators: [ 26 26 (Story) => (<Provider><Story/></Provider>),