mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-07-09 17:28:28 +00:00
feat(ui): Implement Player and api fixtures
This commit is contained in:
parent
3ce94f443a
commit
d4019e6dea
5 changed files with 225 additions and 6 deletions
84
src/client/components/chakraPlayer/Player.tsx
Normal file
84
src/client/components/chakraPlayer/Player.tsx
Normal file
|
|
@ -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 <Stack gap="2">
|
||||
<Flex gap="4" align="center">
|
||||
{playArt !== undefined ? <Image minWidth="48px" flex="0" height="100%" width="100%" src={playArt}></Image> : null}
|
||||
<Center flex="1">
|
||||
<Stack textAlign="center">
|
||||
<Heading textWrap="balance" size="md">{calculated !== 'stopped' ? track : '-'}</Heading>
|
||||
<TextMuted>{calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'}</TextMuted>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Flex>
|
||||
<Progress.Root value={indeterminate || data.position === undefined || duration === undefined ? null : (data.position/duration) * 100} size="sm">
|
||||
<HStack gap="5">
|
||||
<Progress.Label>{indeterminate || data.position === undefined ? '-' : timeToHumanTimestamp(data.position * 1000)}</Progress.Label>
|
||||
<Progress.Track flex="1">
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
<Progress.ValueText>{duration === undefined ? '-' : timeToHumanTimestamp(duration * 1000)}</Progress.ValueText>
|
||||
</HStack>
|
||||
</Progress.Root>
|
||||
<Flex>
|
||||
<TextMuted>{['unknown', 'playing'].includes(calculated) && nowPlayingMode ? 'Now Playing' : capitalize(calculated)}</TextMuted>
|
||||
<Spacer />
|
||||
<TextMuted textAlign="right">Listened: {nowPlayingMode !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</TextMuted>
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
}
|
||||
|
|
@ -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 = <Card.Footer/>;
|
||||
let cardHeaderProps: Card.HeaderProps = {};
|
||||
if(isComponentSourceApiJson(data)) {
|
||||
const {
|
||||
players
|
||||
} = data;
|
||||
if(Object.keys(players).length > 0) {
|
||||
cardHeaderProps.borderBottomWidth="1px";
|
||||
cardHeaderProps.paddingBottom="2";
|
||||
body = (<Card.Body px="3" py="2" paddingTop="3">
|
||||
<Stack gap="2">
|
||||
{
|
||||
Object.values(players).map((x) => <Container maxW="lg" bg="bg.emphasized" borderWidth="1px" p="2" py="3" rounded="md"><ChakraPlayer data={x}/></Container>)
|
||||
}
|
||||
</Stack>
|
||||
</Card.Body>);
|
||||
}
|
||||
}
|
||||
|
||||
return (<Card.Root variant="subtle">
|
||||
<Card.Header>
|
||||
<Card.Header {...cardHeaderProps}>
|
||||
<LinkBox>
|
||||
<Flex justify="space-between">
|
||||
<Heading>{data.name}</Heading>
|
||||
|
|
@ -32,7 +53,7 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => {
|
|||
<TextMuted textStyle="md">{isClient ? `(${data.mode}) ` : ''}{capitalize(data.type)}</TextMuted>
|
||||
<QuickStatsSource data={data} />
|
||||
</Card.Header>
|
||||
<Card.Footer />
|
||||
{body}
|
||||
</Card.Root>)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PlayApiCommon> & {play?: JsonPlayObject | PlayObject } = {}, ...playOpts: Parameters<typeof generatePlay>): PlayApiCommon => {
|
||||
let play: JsonPlayObject | PlayObject;
|
||||
|
|
@ -160,7 +161,7 @@ export const generateSourceApiJson = (data: Partial<ComponentSourceApi> = {}): 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<ComponentCommonApi> = {})
|
|||
return generateSourceApiJson({mode, type, ...data});
|
||||
}
|
||||
return generateClientApiJson({mode, type, ...data});
|
||||
}
|
||||
|
||||
export const generateSourcePlayerJson = (data: Partial<SourcePlayerJson> = {}, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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})}})
|
||||
}
|
||||
});
|
||||
61
src/stories/Player.stories.tsx
Normal file
61
src/stories/Player.stories.tsx
Normal file
|
|
@ -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 (<ChakraPlayer {...args} />)
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (<Provider><Container maxWidth="lg"><Story/></Container></Provider>),
|
||||
]
|
||||
// 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 }
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue