diff --git a/src/client/components/chakraPlayer/Player.tsx b/src/client/components/chakraPlayer/Player.tsx new file mode 100644 index 00000000..b9103c90 --- /dev/null +++ b/src/client/components/chakraPlayer/Player.tsx @@ -0,0 +1,84 @@ +import React, { ComponentProps, useMemo, forwardRef, Fragment } from "react" +import { Accordion, Progress, For, Span, Stack, Spacer, Text, Image, Box, Heading, AbsoluteCenter, Button, Separator, HStack, Flex, Center, Badge, IconButton, Container, Collapsible, Card, LinkOverlay, LinkBox } from '@chakra-ui/react'; +import { TextMuted } from "../TextMuted"; +import { SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerJson } from "../../../core/Atomic"; +import { timeToHumanTimestamp } from "../../../core/TimeUtils"; +import { capitalize } from "../../../core/StringUtils"; + +export interface PlayerProps { + data: SourcePlayerJson + sot?: SOURCE_SOT_TYPES +} + +export const ChakraPlayer = (props: PlayerProps) => { + + const { + data, + sot = SOURCE_SOT.PLAYER + } = props; + + const { + play: { + data: { + track = '???', + artists = [{ name: '???' }], + duration = 0 + } = {}, + meta: { + art = {}, + } = {} + } = {}, + play, + listenedDuration = 0, + nowPlayingMode = false, + status: { + calculated = '???', + reported, + stale, + orphaned + } + } = data; + + const playArt = art.track ?? art.album ?? art.artist ?? undefined; + + let durPer = null; + if (!nowPlayingMode) { + if (duration !== undefined && duration !== null && duration !== 0) { + if (listenedDuration === 0 || listenedDuration === null) { + durPer = ' (0%)'; + } else { + durPer = ` (${((listenedDuration / duration) * 100).toFixed(0)}%)`; + } + } + } + + const indeterminate = nowPlayingMode || (calculated === 'playing' && data.position === undefined); + + + return + + {playArt !== undefined ? : null} +
+ + {calculated !== 'stopped' ? track : '-'} + {calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'} + +
+
+ + + {indeterminate || data.position === undefined ? '-' : timeToHumanTimestamp(data.position * 1000)} + + + + {duration === undefined ? '-' : timeToHumanTimestamp(duration * 1000)} + + + + {['unknown', 'playing'].includes(calculated) && nowPlayingMode ? 'Now Playing' : capitalize(calculated)} + + Listened: {nowPlayingMode !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer} + +
+ +} \ No newline at end of file diff --git a/src/client/components/msComponent/MSComponentQuickDisplay.tsx b/src/client/components/msComponent/MSComponentQuickDisplay.tsx index 59f2084b..d7d0397b 100644 --- a/src/client/components/msComponent/MSComponentQuickDisplay.tsx +++ b/src/client/components/msComponent/MSComponentQuickDisplay.tsx @@ -6,14 +6,35 @@ import { isClientType } from "../../../backend/common/infrastructure/Atomic"; import { capitalize } from "../../../core/StringUtils"; import { ShortDateDisplay } from "../DateDisplay"; import { ChevronRightButton } from "../icons/ChakraIcons"; +import { ChakraPlayer } from "../chakraPlayer/Player"; export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => { const { data } = props; const isClient = isClientType(data.type); + + let body = ; + let cardHeaderProps: Card.HeaderProps = {}; + if(isComponentSourceApiJson(data)) { + const { + players + } = data; + if(Object.keys(players).length > 0) { + cardHeaderProps.borderBottomWidth="1px"; + cardHeaderProps.paddingBottom="2"; + body = ( + + { + Object.values(players).map((x) => ) + } + + ); + } + } + return ( - + {data.name} @@ -32,7 +53,7 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => { {isClient ? `(${data.mode}) ` : ''}{capitalize(data.type)} - + {body} ) } diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 3a349c1b..a813b0ea 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import { ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentSourceApi, ComponentSourceApiJson, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi } from "../../Api.js"; -import { CLIENT_INGRESS_QUEUE, JsonPlayObject, PlayObject, PlayState, QUEUE_STATUSES, SOURCE_SOT, sourceSotTypes } from "../../Atomic.js"; +import { CLIENT_INGRESS_QUEUE, JsonPlayObject, PlayObject, PlayState, QUEUE_STATUSES, SOURCE_SOT, SourcePlayerJson, sourceSotTypes } from "../../Atomic.js"; import { generatePlay } from "../../PlayTestUtils.js"; import { generatePlayInput, randomPlayState } from "./fixtures.js"; import { asJsonPlayObject } from "../../PlayMarshalUtils.js"; @@ -11,7 +11,8 @@ import { nanoid } from "nanoid"; import { isSourceType, SourceType, sourceTypes } from "../../../backend/common/infrastructure/config/source/sources.js"; import { ClientType, clientTypes } from "../../../backend/common/infrastructure/config/client/clients.js"; import { ComponentSelect } from "../../../backend/common/database/drizzle/drizzleTypes.js"; -import { isClientType } from "../../../backend/common/infrastructure/Atomic.js"; +import { CALCULATED_PLAYER_STATUSES, isClientType, REPORTED_PLAYER_STATUSES } from "../../../backend/common/infrastructure/Atomic.js"; +import { faMarker } from "@fortawesome/free-solid-svg-icons"; export const generatePlayApiCommon = (commonData: Partial & {play?: JsonPlayObject | PlayObject } = {}, ...playOpts: Parameters): PlayApiCommon => { let play: JsonPlayObject | PlayObject; @@ -160,7 +161,7 @@ export const generateSourceApiJson = (data: Partial = {}): C manualListening = faker.datatype.boolean({probability: 0.1}), systemListeningBehavior = true, tracksDiscovered = faker.number.int({min: 1, max: 2000}), - players = {} + players = (data.players ?? {}) } = data; return { ...common, @@ -222,4 +223,50 @@ export const generateComponentApiJson = (data: Partial = {}) return generateSourceApiJson({mode, type, ...data}); } return generateClientApiJson({mode, type, ...data}); +} + +export const generateSourcePlayerJson = (data: Partial = {}, opts: {art?: boolean} = {}): SourcePlayerJson => { + const { + platformId = `${faker.word.noun()}-${faker.word.adjective()}`, + play = asJsonPlayObject(generatePlay()), + position = play.meta.trackProgressPosition ?? faker.number.int({min: 0, max: play.data.duration}), + listenedDuration = play.data.listenedFor ?? faker.number.int({min: 0, max: play.data.duration}), + playFirstSeenAt = dayjs().subtract(30, 's').toISOString(), + playLastUpdatedAt = dayjs().subtract(10, 's').toISOString(), + playerLastUpdatedAt = dayjs().subtract(10, 's').toISOString(), + createdAt = dayjs().unix(), + status: { + reported = REPORTED_PLAYER_STATUSES.playing, + calculated = CALCULATED_PLAYER_STATUSES.playing, + stale = false, + orphaned = false + } = {} + } = data; + + if(opts.art) { + play.meta = { + ...(play.meta), + art: { + album: 'https://placehold.co/400', + ...(play.meta?.art ?? {}) + } + } + } + + return { + platformId, + play, + position, + listenedDuration, + playerLastUpdatedAt, + playFirstSeenAt, + playLastUpdatedAt, + createdAt, + status: { + reported, + calculated, + stale, + orphaned + } + } } \ No newline at end of file diff --git a/src/stories/ComponentSummary.stories.tsx b/src/stories/ComponentSummary.stories.tsx index 1d3983ec..15e77673 100644 --- a/src/stories/ComponentSummary.stories.tsx +++ b/src/stories/ComponentSummary.stories.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Container } from '@chakra-ui/react'; import { MSComponentSummary } from "../client/components/msComponent/MSComponentQuickDisplay"; import {Provider} from "../client/components/Provider"; -import { generateClientApiJson, generateSourceApiJson } from "../core/tests/utils/apiFixtures.js"; +import { generateClientApiJson, generateSourceApiJson, generateSourcePlayerJson } from "../core/tests/utils/apiFixtures.js"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ @@ -46,4 +46,10 @@ export const ClientSummary = meta.story({ args: { data: generateClientApiJson() } +}); + +export const SourceWithPlayerSummary = meta.story({ + args: { + data: generateSourceApiJson({players: {test: generateSourcePlayerJson(undefined, {art: true})}}) + } }); \ No newline at end of file diff --git a/src/stories/Player.stories.tsx b/src/stories/Player.stories.tsx new file mode 100644 index 00000000..8f97cef3 --- /dev/null +++ b/src/stories/Player.stories.tsx @@ -0,0 +1,61 @@ +import preview from "../../.storybook/preview.js"; +import React from 'react'; + +import { Container } from '@chakra-ui/react'; +import { ChakraPlayer } from "../client/components/chakraPlayer/Player"; +import {Provider} from "../client/components/Provider"; +import { generateClientApiJson, generateSourceApiJson, generateSourcePlayerJson } from "../core/tests/utils/apiFixtures.js"; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = preview.meta({ + title: 'Examples/Player', + component: ChakraPlayer, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'padded', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/api/argtypes + // args: { + // data: generatePlayApiCommonDetailed(), + // }, + // argTypes: { + // componentType: { + // control: { type: 'select' }, + // options: ['source', 'client'], + // } + // }, + render: function Render(args) { + return () + }, +decorators: [ + (Story) => (), + ] + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args +}); + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const PlayerNoArt = meta.story({ + args: { + data: generateSourcePlayerJson() + } +}); + +export const PlayerWithArt = meta.story({ + args: { + data: generateSourcePlayerJson(undefined, {art: true}) + } +}); + +export const PlayerNowPlaying = meta.story({ + args: { + data: {...generateSourcePlayerJson(undefined, {art: true}), nowPlayingMode: true } + } +}); + +export const PlayerNoPosition = meta.story({ + args: { + data: {...generateSourcePlayerJson(undefined, {art: true}), position: undefined } + } +}); \ No newline at end of file