[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): Update artist rendering to use credits

FoxxMD (May 30, 2026, 7:05 PM UTC) 31c35647 204ee159

+95 -20
+38
src/client/components/ArtistCreditDisplay.tsx
··· 1 + import React, { Fragment } from 'react'; 2 + import { ArtistCredit as AC } from '../../core/Atomic'; 3 + 4 + import { HStack, Tag } from "@chakra-ui/react" 5 + import { SiMusicbrainz } from "react-icons/si"; 6 + import { Tooltip } from './ChakraTooltip'; 7 + 8 + export const ArtistCredit = (props: { data: AC, showIdLink?: boolean }) => { 9 + 10 + const { 11 + data, 12 + showIdLink = true 13 + } = props; 14 + 15 + if (!showIdLink || Object.keys(data).length === 1) { 16 + return data.name; 17 + } 18 + 19 + return <Fragment> 20 + <HStack> 21 + {data.name} 22 + <HStack style={{ userSelect: 'none' }}> 23 + {data.mbid !== undefined ? <Tooltip content={`MBID ${data.mbid}`} interactive><a target='__blank' href={`https://musicbrainz.org/artist/${data.mbid}`}><SiMusicbrainz /></a></Tooltip> : null} 24 + </HStack> 25 + </HStack> 26 + </Fragment> 27 + 28 + } 29 + 30 + export const ArtistCreditTags = (props: { data: AC[], showIdLink?: boolean }) => { 31 + return <HStack>{props.data.map((x, index) => { 32 + return ( 33 + <Tag.Root key={index}> 34 + <Tag.Label userSelect="all"><ArtistCredit data={x} showIdLink={props.showIdLink} /></Tag.Label> 35 + </Tag.Root> 36 + ); 37 + })}</HStack> 38 + }
+46
src/client/components/ChakraTooltip.tsx
··· 1 + import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react" 2 + import * as React from "react" 3 + 4 + export interface TooltipProps extends ChakraTooltip.RootProps { 5 + showArrow?: boolean 6 + portalled?: boolean 7 + portalRef?: React.RefObject<HTMLElement | null> 8 + content: React.ReactNode 9 + contentProps?: ChakraTooltip.ContentProps 10 + disabled?: boolean 11 + } 12 + 13 + export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>( 14 + function Tooltip(props, ref) { 15 + const { 16 + showArrow, 17 + children, 18 + disabled, 19 + portalled = true, 20 + content, 21 + contentProps, 22 + portalRef, 23 + ...rest 24 + } = props 25 + 26 + if (disabled) return children 27 + 28 + return ( 29 + <ChakraTooltip.Root {...rest}> 30 + <ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger> 31 + <Portal disabled={!portalled} container={portalRef}> 32 + <ChakraTooltip.Positioner> 33 + <ChakraTooltip.Content ref={ref} {...contentProps}> 34 + {showArrow && ( 35 + <ChakraTooltip.Arrow> 36 + <ChakraTooltip.ArrowTip /> 37 + </ChakraTooltip.Arrow> 38 + )} 39 + {content} 40 + </ChakraTooltip.Content> 41 + </ChakraTooltip.Positioner> 42 + </Portal> 43 + </ChakraTooltip.Root> 44 + ) 45 + }, 46 + )
+4 -15
src/client/components/PlayData.tsx
··· 1 1 import React, { Fragment, useMemo, useState } from 'react'; 2 - import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid, Float, Spacer, Icon, Link, Span, Show } from "@chakra-ui/react" 2 + import { EmptyState, DataList, HStack, Tag, Tabs, Wrap, Box, Flex, SegmentGroup, Stack, Text, Tooltip, Separator, IconButton, Container, SimpleGrid, Float, Spacer, Icon, Link, Span, Show } from "@chakra-ui/react" 3 3 import { LuCode, LuText, LuCheck, LuX } from "react-icons/lu" 4 4 import { JsonPlayObject, PlayObjectLifecycleless } from '../../core/Atomic.js'; 5 5 import { shortTodayAwareFormat, timeToHumanTimestamp } from '../../core/TimeUtils.js'; ··· 8 8 import { TextMuted } from './TextMuted.js'; 9 9 import { formatNumber } from '../../core/DataUtils.js'; 10 10 import { Muted } from './Typography.js'; 11 + import { ArtistCreditTags } from './ArtistCreditDisplay.js'; 11 12 12 13 const EmptyPlayData = () => { 13 14 return ( ··· 102 103 <DataList.Item flexGrow="1"> 103 104 <DataList.ItemLabel>Album Artists</DataList.ItemLabel> 104 105 <DataList.ItemValue> 105 - <HStack>{play.data.albumArtists.map((x, index) => { 106 - return ( 107 - <Tag.Root key={index}> 108 - <Tag.Label>{x}</Tag.Label> 109 - </Tag.Root> 110 - ); 111 - })}</HStack> 106 + <ArtistCreditTags data={play.data.albumArtists} /> 112 107 </DataList.ItemValue> 113 108 </DataList.Item> 114 109 ); ··· 151 146 <DataList.ItemLabel>Artists</DataList.ItemLabel> 152 147 <DataList.ItemValue> 153 148 {artists.length === 0 ? <Text color="fg.muted">(No Artists)</Text> : 154 - <HStack>{play.data.artists.map((x, index) => { 155 - return ( 156 - <Tag.Root key={index}> 157 - <Tag.Label>{x.name}</Tag.Label> 158 - </Tag.Root> 159 - ); 160 - })}</HStack>} 149 + <ArtistCreditTags data={play.data.artists} />} 161 150 </DataList.ItemValue> 162 151 </DataList.Item> 163 152 {albumArtistElm}
+3 -3
src/client/components/playActivity/PlayList.tsx
··· 168 168 </Collapsible.Indicator> 169 169 <Stack gap="1" truncate alignItems="flex-start"> 170 170 <Span>{play.data.track}</Span> 171 - <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 171 + <TextMuted truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted> 172 172 <HStack gap="1"> 173 173 <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 174 174 <TextMuted>{play.meta?.source}</TextMuted> ··· 260 260 <Accordion.ItemIndicator /> 261 261 <Stack gap="1" truncate> 262 262 <Span>{play.data.track}</Span> 263 - <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 263 + <TextMuted truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted> 264 264 <HStack gap="1"> 265 265 <ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" /> 266 266 <TextMuted>{play.meta?.source}</TextMuted> ··· 331 331 <Accordion.ItemIndicator /> 332 332 <Stack gap="1" truncate> 333 333 <Span>{play.data.track}</Span> 334 - <TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted> 334 + <TextMuted truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted> 335 335 <HStack gap="1"> 336 336 <ShortDateDisplay date={sortBy === 'played' ? play.data.playDate : play.meta?.seenAt} prefix={sortBy === 'played' ? 'Played' : 'Seen'} /><Separator orientation="vertical" height="4" /> 337 337 <TextMuted>{play.meta?.source}</TextMuted>
+4 -2
src/stories/PlayInfo.stories.tsx
··· 5 5 import { PlayData } from "../client/components/PlayData.js"; 6 6 import {Provider} from "../client/components/Provider"; 7 7 import { Container } from '@chakra-ui/react'; 8 - import { generateArtists, generateJsonPlay, generatePlay, withBrainz } from "../core/PlayTestUtils.js" 8 + import { generateArtistCredits, generateArtists, generateJsonPlay, generatePlay, withBrainz } from "../core/PlayTestUtils.js" 9 9 import clone from "clone"; 10 10 import { asJsonPlayObject } from '../core/PlayMarshalUtils.js'; 11 + import { JsonPlayObject } from "../core/Atomic.js"; 11 12 12 13 type PropsAndCustomArgs = React.ComponentProps<typeof PlayData> & { 13 14 includeAlbumArtists?: boolean; 14 15 defaultFinal?: boolean 15 16 brainz?: boolean 17 + play: JsonPlayObject 16 18 }; 17 19 // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 18 20 const meta = preview.type<{args: PropsAndCustomArgs}>().meta({ ··· 48 50 } 49 51 50 52 if(args.includeAlbumArtists && (args.play.data.albumArtists === undefined || args.play.data.albumArtists.length === 0)) { 51 - const aa = generateArtists(undefined, 2); 53 + const aa = generateArtistCredits(undefined, 2, {mbidVal: true}); 52 54 args.play.data.albumArtists = aa; 53 55 if(args.final !== undefined) { 54 56 args.final.data.albumArtists = aa;