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