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

refactor(ui): Use custom hook for activity fetching

FoxxMD (Jul 1, 2026, 5:12 PM UTC) 53d7de7e 32da14c0

+68 -75
+68 -46
src/client/components/ActivityDetail.tsx
··· 7 7 import { ActivityTimeline } from "./ActivityTimeline"; 8 8 import { ExpandCollapse } from "./ExpandCollapse"; 9 9 import { MsSseEvent, PlayApiCommon, PlayApiCommonDetailed, SortPlaysBy, SortPlaysByProps } from "../../core/Api"; 10 - import { InfiniteData, QueryFunctionContext, queryOptions, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; 10 + import { InfiniteData, QueryFunctionContext, QueryOptions, queryOptions, SuspenseQueriesOptions, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; 11 11 import ky from 'ky'; 12 12 import { baseUrl } from "../utils"; 13 13 import { ShortDateDisplay } from "./DateDisplay"; ··· 22 22 import { useSSEContext, useSSEEvent } from "@flamefrontend/sse-runtime-react"; 23 23 import { DebugCopy, RetryButton } from "./icons/ChakraIcons"; 24 24 25 + type UseActivityQueryOptions = { 26 + msQuery?: QueryPlaysOptsJson 27 + activity?: ActivitySummaryProps['activity'] 28 + refetchOnMount?: boolean | 'always' 29 + } 30 + export function useActivityQuery( 31 + componentId: number, 32 + activityUid: string, 33 + options: UseActivityQueryOptions = {} 34 + ) { 35 + const { 36 + msQuery, 37 + activity: preloadedActivity, 38 + ...rest 39 + } = options; 40 + const queryClient = useQueryClient(); 41 + 42 + const { isPending, isError, data: activity, error } = useQuery({ 43 + ...tanQueries.activities.single(componentId, activityUid), 44 + ...rest, 45 + staleTime: Infinity, 46 + initialData: () => { 47 + if (msQuery === undefined && preloadedActivity === undefined) { 48 + return undefined; 49 + } 50 + if(preloadedActivity !== undefined) { 51 + return preloadedActivity; 52 + } 53 + const data = queryClient.getQueryData( 54 + tanQueries.activities.list(componentId, msQuery).queryKey 55 + ) as InfiniteData<PaginatedResponse<PlayApiCommonDetailed>> | undefined; 56 + 57 + if (data !== undefined) { 58 + for (const p of data.pages) { 59 + const res = p.data.find(x => x.uid === activityUid); 60 + if (res !== undefined) { 61 + return res; 62 + } 63 + } 64 + return undefined; 65 + } 66 + }, 67 + }); 68 + 69 + const client = useSSEContext<MsSseEvent>(); 70 + useSSEEvent(client, 'playUpdate', (payload) => { 71 + if (payload.componentId === componentId && payload.data.uid === activityUid) { 72 + queryClient.invalidateQueries({ 73 + queryKey: tanQueries.activities.single(componentId, activityUid).queryKey, 74 + refetchType: 'all' 75 + }); 76 + } 77 + }); 78 + 79 + return { activity, isPending, isError, error }; 80 + } 25 81 export interface ActivityDetailProps { 26 82 activity: PlayApiCommonDetailed 27 83 componentType: ComponentType ··· 57 113 } 58 114 59 115 export const ActivitySummaryFetchable = (props: MarkOptional<ActivitySummaryProps, 'activity'> & { componentId: number, activityUid: string, query: QueryPlaysOptsJson}) => { 60 - const queryClient = useQueryClient(); 61 - const { isPending, isError, data, error } = useSuspenseQuery({ 62 - ...tanQueries.activities.single(props.componentId, props.activityUid), 63 - staleTime: Infinity, 64 - initialData: () => { 65 - const data = queryClient.getQueryData(tanQueries.activities.list(props.componentId, props.query).queryKey) as InfiniteData<PaginatedResponse<PlayApiCommonDetailed>> | undefined; 66 - if(data !== undefined) { 67 - for(const p of data.pages) { 68 - const res = p.data.find(x => x.uid === props.activityUid); 69 - if(res !== undefined) { 70 - return res; 71 - } 72 - } 73 - return undefined; 74 - } 75 - } 76 - }); 77 - 78 - const client = useSSEContext<MsSseEvent>(); 79 - useSSEEvent(client, 'playUpdate', (payload) => { 80 - if(payload.componentId === props.componentId && payload.data.uid === props.activityUid) { 81 - queryClient.invalidateQueries({ 82 - queryKey: tanQueries.activities.single(props.componentId, props.activityUid).queryKey, 83 - refetchType: "all" 84 - }); 85 - } 86 - }); 116 + const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.activityUid, {activity: props.activity}); 87 117 88 118 if(isError) { 89 119 return <ErrorAlert error={error}/> 90 120 } 91 121 92 - return <ActivitySummary {...props} activity={data}/> 122 + if(activity === undefined && isPending) { 123 + return <ActivitySummarySkeleton/>; 124 + } 125 + 126 + return <ActivitySummary {...props} activity={activity}/> 93 127 } 94 128 95 129 export const ActivityDetails = (props: ActivityDetailProps) => { ··· 164 198 uid: string 165 199 componentId: number 166 200 componentType: ComponentType 167 - query: QueryPlaysOptsJson 201 + activity?: ActivitySummaryProps['activity'] 168 202 } 169 203 170 204 export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => { 171 - const { isPending, isError, data, error } = useQuery({ 172 - ...tanQueries.activities.single(props.componentId, props.uid) 173 - }); 174 - 175 - const queryClient = useQueryClient(); 176 - const client = useSSEContext<MsSseEvent>(); 177 - useSSEEvent(client, 'playUpdate', (payload) => { 178 - if(payload.componentId === props.componentId && payload.data.uid === props.uid) { 179 - queryClient.invalidateQueries({ 180 - queryKey: tanQueries.activities.single(props.componentId, props.uid).queryKey 181 - }); 182 - } 183 - }); 205 + const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.uid, {activity: props.activity, refetchOnMount: 'always'}); 184 206 185 207 if(isPending) { 186 208 return <Fragment><Skeleton height="100px"/><Skeleton height="100px"/></Fragment> ··· 190 212 return <ErrorAlert error={error}/> 191 213 } 192 214 193 - return <ActivityDetails componentType={props.componentType} key={data?.uid} activity={data}/> 215 + return <ActivityDetails componentType={props.componentType} key={props.uid} activity={activity as PlayApiCommonDetailed}/> 194 216 } 195 217 196 218 export const ActivityCollapsible = (props: ActivitySummaryProps & { key?: string, live?: boolean, componentId: number, query: QueryPlaysOptsJson }) => { ··· 233 255 > 234 256 <LuChevronRight /> 235 257 </Collapsible.Indicator> 236 - {live ? <ActivitySummaryFetchable activityUid={activity.uid} {...props} /> : <ActivitySummary componentType={props.componentType} activity={activity} sortBy={sortBy} />} 258 + <ActivitySummaryFetchable activityUid={activity.uid} {...props}/> 237 259 </Collapsible.Trigger> 238 260 <Collapsible.Content borderTopColor="gray.border" 239 261 style={{ 240 262 paddingBlock: "var(--chakra-spacing-4)", 241 263 paddingInline: "var(--chakra-spacing-4)" 242 264 }}> 243 - {live ? <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} query={props.query} uid={activity.uid} /> : <ActivityDetails {...props} activity={activity as PlayApiCommonDetailed} />} 265 + <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} uid={activity.uid} activity={activity} /> 244 266 </Collapsible.Content> 245 267 </Collapsible.Root> 246 268 )
-29
src/stories/plays/ActivityList.stories.tsx
··· 47 47 let playData: PlayApiCommonDetailed[] = []; 48 48 let livePlayData: PlayApiCommonDetailed[] = []; 49 49 50 - // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 51 - export const List = meta.story({ 52 - args: { 53 - render: "virtDynamic" 54 - }, 55 - 56 - parameters: { 57 - msw: { 58 - handlers: [ 59 - http.get<{uid: string}>('/api/components/:componentId/play/:uid', async ({ params }) => { 60 - const existing = playData.find(x => x.uid === params.uid); 61 - if(existing !== undefined) { 62 - return HttpResponse.json(existing); 63 - } 64 - return HttpResponse.json(generatePlayApiCommonDetailed()); 65 - }), 66 - ], 67 - }, 68 - }, 69 - 70 - //render: function Render(args) { return (<ChakraProvider><MyList></MyList></ChakraProvider>) } 71 - loaders: [ 72 - async () => { 73 - playData = await generatePlayApiCommonDetailedList() 74 - return {data: playData}; 75 - } 76 - ] 77 - }); 78 - 79 50 export const ListLive = meta.story({ 80 51 component: ListContainerFetchable, 81 52 render: function Render(args, { loaded: { data } }) { return (<ListContainerFetchable {...args}/>) },