From 010224954983f1a2cc6fe0b94b7dc28f9b485520 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 18 Mar 2026 16:44:17 +0000 Subject: [PATCH] feat(ui): Implement PlayInfo component --- src/backend/common/Cache.ts | 2 +- src/backend/common/vendor/LastfmApiClient.ts | 3 +- .../scrobblers/AbstractScrobbleClient.ts | 2 +- src/backend/server/middleware.ts | 3 - src/backend/sources/AbstractSource.ts | 3 +- src/backend/sources/DeezerSource.ts | 12 +- .../PlayerState/AbstractPlayerState.ts | 3 +- src/backend/sources/SpotifySource.ts | 2 +- src/backend/sources/YTMusicSource.ts | 2 +- src/backend/tests/jellyfin/jellyfin.test.ts | 2 +- src/backend/tests/plex/plex.test.ts | 2 +- src/backend/tests/source/source.test.ts | 4 +- src/backend/tests/utils/PlayTestUtils.ts | 10 ++ src/backend/utils.ts | 67 --------- src/backend/utils/FSUtils.ts | 65 ++++++++ src/backend/utils/ListenFetchUtils.ts | 2 +- src/backend/utils/PlayTransformUtils.ts | 3 +- src/backend/utils/TimeUtils.ts | 11 +- src/client/components/ChakraClipboard.tsx | 22 +++ src/client/components/PlayInfo.tsx | 142 ++++++++++++++++++ src/core/Atomic.ts | 5 +- src/core/StringUtils.ts | 4 +- src/core/TimeUtils.ts | 16 ++ src/stories/PlayInfo.stories.tsx | 38 +++++ src/stories/tsconfig.json | 3 +- 25 files changed, 321 insertions(+), 107 deletions(-) create mode 100644 src/backend/utils/FSUtils.ts create mode 100644 src/client/components/ChakraClipboard.tsx create mode 100644 src/client/components/PlayInfo.tsx create mode 100644 src/core/TimeUtils.ts create mode 100644 src/stories/PlayInfo.stories.tsx diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index 6dcb0691..c29c1c69 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -13,7 +13,7 @@ import { childLogger, Logger } from '@foxxmd/logging'; import { projectDir } from './index.js'; import path from 'path'; import { cacheFunctions } from "@foxxmd/regex-buddy-core"; -import { fileOrDirectoryIsWriteable } from '../utils.js'; +import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.js'; import { asCacheAuthProvider, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js'; import { Typeson } from 'typeson'; import { builtin } from 'typeson-registry'; diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index ffb46b91..9e57d56f 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -1,7 +1,8 @@ import dayjs, { Dayjs, ManipulateType } from "dayjs"; import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp, URLData, Writeable } from "../../../core/Atomic.js"; import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; -import { removeUndefinedKeys, sleep, writeFile } from "../../utils.js"; +import { removeUndefinedKeys, sleep } from "../../utils.js"; +import { writeFile } from '../../utils/FSUtils.js'; import { objectIsEmpty, readJson } from '../../utils/DataUtils.js'; import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../../utils/NetworkUtils.js"; import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js"; diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index ded4da9a..7619372d 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -52,8 +52,8 @@ import { hasAcceptableTemporalAccuracy, temporalAccuracyToString, temporalPlayComparisonSummary, - todayAwareFormat, } from "../utils/TimeUtils.js"; +import { todayAwareFormat } from "../../core/TimeUtils.js"; import { WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; import { getRoot } from "../ioc.js"; diff --git a/src/backend/server/middleware.ts b/src/backend/server/middleware.ts index 06dc5a99..b716b12b 100644 --- a/src/backend/server/middleware.ts +++ b/src/backend/server/middleware.ts @@ -4,9 +4,7 @@ import { ExpressHandler } from "../common/infrastructure/Atomic.js"; export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): ExpressHandler => (req: any, res: any, next: any) => { const { query: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message name, - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message type } = {} } = req; @@ -30,7 +28,6 @@ export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): Exp export const makeClientCheckMiddle = (clients: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => { const { query: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message name } = {} } = req; diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 19e3c5c6..2bfbbdfd 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -33,7 +33,8 @@ import { sortByNewestPlayDate, sortByOldestPlayDate, } from "../utils.js"; -import { timeToHumanTimestamp, todayAwareFormat } from "../utils/TimeUtils.js"; +import { timeToHumanTimestamp } from "../utils/TimeUtils.js"; +import { todayAwareFormat } from "../../core/TimeUtils.js"; import { getRoot } from '../ioc.js'; import { componentFileLogger } from '../common/logging.js'; import { WebhookPayload } from '../common/infrastructure/config/health/webhooks.js'; diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index 1cd98e40..9a37def2 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -6,7 +6,8 @@ import request from 'superagent'; import { PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; -import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js"; +import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, } from "../utils.js"; +import { writeFile } from '../utils/FSUtils.js'; import { readJson } from '../utils/DataUtils.js'; import { joinedUrl } from "../utils/NetworkUtils.js"; import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; @@ -61,7 +62,6 @@ export default class DeezerSource extends AbstractSource { const { title: name, artist: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message name: artistName, } = {}, duration, @@ -69,7 +69,6 @@ export default class DeezerSource extends AbstractSource { id, link, album: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message title: albumName, } = {}, } = obj; @@ -162,7 +161,6 @@ export default class DeezerSource extends AbstractSource { const { body = {}, body: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message error, } = {} } = resp; @@ -187,23 +185,17 @@ export default class DeezerSource extends AbstractSource { const { message, response: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message status, body: { "subsonic-response": { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message status: ssStatus, error: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message code, - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message message: ssMessage, } = {}, } = {}, - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message "subsonic-response": ssResp } = {}, - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message text, } = {}, response, diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index f533d0fe..ae75c651 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -16,7 +16,8 @@ import { PollingOptions } from "../../common/infrastructure/config/common.js"; import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js"; import { ListenProgress } from "./ListenProgress.js"; import { ListenRange, ListenRangePositional } from "./ListenRange.js"; -import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timeToHumanTimestamp, todayAwareFormat } from "../../utils/TimeUtils.js"; +import { closeToPlayEnd, closeToPlayStart, repeatDurationPlayed, timeToHumanTimestamp } from "../../utils/TimeUtils.js"; +import { todayAwareFormat } from "../../../core/TimeUtils.js"; export interface PlayerStateIntervals { staleInterval?: number diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 2b23ff76..ae4db007 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -25,8 +25,8 @@ import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, - writeFile, } from "../utils.js"; +import { writeFile } from '../utils/FSUtils.js'; import { readJson } from '../utils/DataUtils.js'; import { findCauseByFunc } from "../utils/ErrorUtils.js"; import { joinedUrl } from "../utils/NetworkUtils.js"; diff --git a/src/backend/sources/YTMusicSource.ts b/src/backend/sources/YTMusicSource.ts index 4c0987ff..c188f9a8 100644 --- a/src/backend/sources/YTMusicSource.ts +++ b/src/backend/sources/YTMusicSource.ts @@ -19,7 +19,7 @@ import { import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; import { joinedUrl } from "../utils/NetworkUtils.js"; -import { todayAwareFormat } from "../utils/TimeUtils.js"; +import { todayAwareFormat } from "../../core/TimeUtils.js"; import { parseArrayFromMaybeString, parseArtistCredits, parseCredits } from "../utils/StringUtils.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; diff --git a/src/backend/tests/jellyfin/jellyfin.test.ts b/src/backend/tests/jellyfin/jellyfin.test.ts index 0b14a33b..229402f4 100644 --- a/src/backend/tests/jellyfin/jellyfin.test.ts +++ b/src/backend/tests/jellyfin/jellyfin.test.ts @@ -5,7 +5,7 @@ import { describe, it } from 'mocha'; import { JsonPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js"; import JellyfinApiSource from "../../sources/JellyfinApiSource.js"; -import validSession from './validSession.json'; +import validSession from './validSession.json' with { type: "json" }; import { JellyApiData } from "../../common/infrastructure/config/source/jellyfin.js"; import { generatePlay } from "../utils/PlayTestUtils.js"; import { fakerJA } from "@faker-js/faker"; diff --git a/src/backend/tests/plex/plex.test.ts b/src/backend/tests/plex/plex.test.ts index f2ddb38d..3cd26804 100644 --- a/src/backend/tests/plex/plex.test.ts +++ b/src/backend/tests/plex/plex.test.ts @@ -4,7 +4,7 @@ import EventEmitter from "events"; import { describe, it } from 'mocha'; import { JsonPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js"; -import validSessionResponse from './validSession.json'; +import validSessionResponse from './validSession.json' with { type: "json" }; import { generatePlay } from "../utils/PlayTestUtils.js"; import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; import { PlexApiData } from "../../common/infrastructure/config/source/plex.js"; diff --git a/src/backend/tests/source/source.test.ts b/src/backend/tests/source/source.test.ts index 1056aeb8..873ceaca 100644 --- a/src/backend/tests/source/source.test.ts +++ b/src/backend/tests/source/source.test.ts @@ -8,15 +8,13 @@ import clone from 'clone'; import { PlayObject } from "../../../core/Atomic.js"; import { generatePlay, generatePlayerStateData, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js"; import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js"; -import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json'; +import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json' with { type: "json" }; import SpotifySource from "../../sources/SpotifySource.js"; import MockDate from 'mockdate'; import dayjs, { Dayjs } from "dayjs"; import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; import { SourceConfig } from "../../common/infrastructure/config/source/sources.js"; import MemorySource from "../../sources/MemorySource.js"; -import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js"; -import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js"; import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js"; import { sleep } from "../../utils.js"; import DeezerInternalSource from "../../sources/DeezerInternalSource.js"; diff --git a/src/backend/tests/utils/PlayTestUtils.ts b/src/backend/tests/utils/PlayTestUtils.ts index aee77b9c..d5927a00 100644 --- a/src/backend/tests/utils/PlayTestUtils.ts +++ b/src/backend/tests/utils/PlayTestUtils.ts @@ -182,6 +182,16 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional): JsonPlayObject => { + const play = generatePlay(...args); + return JSON.parse(JSON.stringify(play)); +} + +export const generateJsonPlays = (...args: Parameters): JsonPlayObject[] => { + const plays = generatePlays(...args); + return JSON.parse(JSON.stringify(plays)); +} + export const withBrainz = (play: PlayObject, include: ('track' | 'artist' | 'album')[]): PlayObject => { for(const i of include) { switch(i) { diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 16cfc70a..0f216c8a 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -4,9 +4,7 @@ import dayjs, { Dayjs } from 'dayjs'; import { Duration } from "dayjs/plugin/duration.js"; import utc from 'dayjs/plugin/utc.js'; import { Request } from "express"; -import { accessSync, constants, promises } from "fs"; // https://github.com/jfromaniello/url-join#in-nodejs -import pathUtil from "path"; import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js"; import { DEFAULT_MISSING_MBIDS_TYPES, DEFAULT_MISSING_TYPES, MissingMbidType, PlayObject } from "../core/Atomic.js"; import { @@ -25,40 +23,6 @@ import { //const { default: Ajv } = AjvNS; dayjs.extend(utc); -export async function readText(path: any) { - await promises.access(path, constants.R_OK); - const data = await promises.readFile(path); - return data.toString(); - - // return new Promise((resolve, reject) => { - // fs.readFile(path, 'utf8', function (err, data) { - // if (err) { - // reject(err); - // } - // resolve(JSON.parse(data)); - // }); - // }); -} - -export async function writeFile(path: any, text: any) { - // await promises.access(path, constants.W_OK | constants.O_CREAT); - try { - await promises.writeFile(path, text, 'utf8'); - } catch (e) { - throw e; - } - - // return new Promise((resolve, reject) => { - // fs.readFile(path, 'utf8', function (err, data) { - // if (err) { - // reject(err); - // } - // resolve(JSON.parse(data)); - // }); - // }); -} - - export function sleep(ms: any) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -212,7 +176,6 @@ export const parseRetryAfterSecsFromObj = (err: any) => { // } const { response: { - // @ts-expect-error headers, // returned in superagent error } = {}, retryAfter: ra // possible custom property we have set @@ -382,36 +345,6 @@ export const getPlatformIdFromData = (data: PlayObject | PlayerStateDataMaybePla return genGroupId(data); } -export const fileOrDirectoryIsWriteable = (location: string) => { - const pathInfo = pathUtil.parse(location); - const isDir = pathInfo.ext === ''; - try { - accessSync(location, constants.R_OK | constants.W_OK); - return true; - } catch (err: any) { - const {code} = err; - if (code === 'ENOENT') { - // file doesn't exist, see if we can write to directory in which case we are good - try { - accessSync(pathInfo.dir, constants.R_OK | constants.W_OK) - // we can write to dir - return true; - } catch (accessError: any) { - if(accessError.code === 'EACCES') { - // also can't access directory :( - throw new Error(`No ${isDir ? 'directory' : 'file'} exists at ${location} and application does not have permission to write to the parent directory`); - } else { - throw new Error(`No ${isDir ? 'directory' : 'file'} exists at ${location} and application is unable to access the parent directory due to a system error`, {cause: accessError}); - } - } - } else if(code === 'EACCES') { - throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application does not have permission to write to it.`); - } else { - throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application is unable to access it due to a system error`, {cause: err}); - } - } -} - export const mergeArr = (objValue: [], srcValue: []): (any[] | undefined) => { if (Array.isArray(objValue)) { return objValue.concat(srcValue); diff --git a/src/backend/utils/FSUtils.ts b/src/backend/utils/FSUtils.ts new file mode 100644 index 00000000..dca0d517 --- /dev/null +++ b/src/backend/utils/FSUtils.ts @@ -0,0 +1,65 @@ +import { accessSync, constants } from "fs"; +import { promises } from "node:fs"; +import pathUtil from "path"; + + +export async function readText(path: any) { + await promises.access(path, constants.R_OK); + const data = await promises.readFile(path); + return data.toString(); + + // return new Promise((resolve, reject) => { + // fs.readFile(path, 'utf8', function (err, data) { + // if (err) { + // reject(err); + // } + // resolve(JSON.parse(data)); + // }); + // }); +}export async function writeFile(path: any, text: any) { + // await promises.access(path, constants.W_OK | constants.O_CREAT); + try { + await promises.writeFile(path, text, 'utf8'); + } catch (e) { + throw e; + } + + // return new Promise((resolve, reject) => { + // fs.readFile(path, 'utf8', function (err, data) { + // if (err) { + // reject(err); + // } + // resolve(JSON.parse(data)); + // }); + // }); +} +export const fileOrDirectoryIsWriteable = (location: string) => { + const pathInfo = pathUtil.parse(location); + const isDir = pathInfo.ext === ''; + try { + accessSync(location, constants.R_OK | constants.W_OK); + return true; + } catch (err: any) { + const { code } = err; + if (code === 'ENOENT') { + // file doesn't exist, see if we can write to directory in which case we are good + try { + accessSync(pathInfo.dir, constants.R_OK | constants.W_OK); + // we can write to dir + return true; + } catch (accessError: any) { + if (accessError.code === 'EACCES') { + // also can't access directory :( + throw new Error(`No ${isDir ? 'directory' : 'file'} exists at ${location} and application does not have permission to write to the parent directory`); + } else { + throw new Error(`No ${isDir ? 'directory' : 'file'} exists at ${location} and application is unable to access the parent directory due to a system error`, { cause: accessError }); + } + } + } else if (code === 'EACCES') { + throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application does not have permission to write to it.`); + } else { + throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application is unable to access it due to a system error`, { cause: err }); + } + } +}; + diff --git a/src/backend/utils/ListenFetchUtils.ts b/src/backend/utils/ListenFetchUtils.ts index 7efaee29..66d80005 100644 --- a/src/backend/utils/ListenFetchUtils.ts +++ b/src/backend/utils/ListenFetchUtils.ts @@ -5,7 +5,7 @@ import { PlayObject, UnixTimestamp } from "../../core/Atomic.js"; import { CursorType, hasPagelessTimeRangeListens, hasPaginatedTimeRangeListens, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult, PaginatedListensTimeRangeOptions, PaginatedTimeRangeCommonOptions, PaginatedTimeRangeListens, PaginatedTimeRangeListensResult, PaginatedTimeRangeOptions, PaginatedTimeRangeSource, REFRESH_STALE_DEFAULT, TimeRangeListensFetcher } from "../common/infrastructure/Atomic.js"; import { loggerNoop, MaybeLogger } from "../common/logging.js"; import { sortByNewestPlayDate, sortByOldestPlayDate } from "../utils.js"; -import { todayAwareFormat } from "./TimeUtils.js"; +import { todayAwareFormat } from "../../core/TimeUtils.js"; import { playDateWithinDurationOfAny } from "./PlayComparisonUtils.js"; export interface TimeRangeFetchOptions { diff --git a/src/backend/utils/PlayTransformUtils.ts b/src/backend/utils/PlayTransformUtils.ts index a9c22960..0a48a6a7 100644 --- a/src/backend/utils/PlayTransformUtils.ts +++ b/src/backend/utils/PlayTransformUtils.ts @@ -1,7 +1,6 @@ import { Logger, loggerTest } from "@foxxmd/logging"; import { searchAndReplace as searchAndReplaceFunc, testMaybeRegex as testMaybeRegexFunc } from "@foxxmd/regex-buddy-core"; -import { ObjectPlayData, PlayLifecycle, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; -import { buildTrackString } from "../../core/StringUtils.js"; +import { PlayLifecycle, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { ConditionalSearchAndReplaceRegExp, diff --git a/src/backend/utils/TimeUtils.ts b/src/backend/utils/TimeUtils.ts index b1a4ec8d..6ce4c604 100644 --- a/src/backend/utils/TimeUtils.ts +++ b/src/backend/utils/TimeUtils.ts @@ -1,6 +1,6 @@ import dayjs, { Dayjs } from "dayjs"; -import isToday from 'dayjs/plugin/isToday.js'; +//import isToday from 'dayjs/plugin/isToday.js'; import { AcceptableTemporalDuringReference, PlayObject, @@ -31,7 +31,7 @@ import { import { ScrobbleThresholds } from "../common/infrastructure/config/source/index.js"; import { formatNumber } from "../utils.js"; -dayjs.extend(isToday); +//dayjs.extend(isToday); export const temporalPlayComparisonSummary = (data: TemporalPlayComparison, existingPlay?: PlayObject, candidatePlay?: PlayObject) => { const parts: string[] = []; @@ -277,13 +277,6 @@ export const getScrobbleTsSOCDate = (data: PlayObject): Dayjs => { return date; } -export const todayAwareFormat = (date: Dayjs, opts: {fullFormat?: string, todayFormat?: string} = {}): string => { - const { - fullFormat, - todayFormat = 'HH:mm:ssZ' - } = opts; - return date.format(date.isToday() ? todayFormat : fullFormat); -}; export const parseDurationFromTimestamp = (timestamp: any) => { if (timestamp === null || timestamp === undefined) { return undefined; diff --git a/src/client/components/ChakraClipboard.tsx b/src/client/components/ChakraClipboard.tsx new file mode 100644 index 00000000..6f32d6cb --- /dev/null +++ b/src/client/components/ChakraClipboard.tsx @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; +import { Clipboard, IconButton } from "@chakra-ui/react" +import {safeStringify} from '../../core/StringUtils'; + + +export const ChakraClip = (props: {value: any}) => { + const clipVal = useMemo(() => { + if(typeof props.value === 'string') { + return props.value; + } + return safeStringify(props.value); + },[props.value]) + return ( + + + + + + + + ) +} \ No newline at end of file diff --git a/src/client/components/PlayInfo.tsx b/src/client/components/PlayInfo.tsx new file mode 100644 index 00000000..dc4f0dc3 --- /dev/null +++ b/src/client/components/PlayInfo.tsx @@ -0,0 +1,142 @@ +import React, { Fragment, useMemo, useState } from 'react'; +import { EmptyState, DataList, HStack, Tag, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator } from "@chakra-ui/react" +import { JsonPlayObject } from '../../core/Atomic'; +import { ChakraClip } from './ChakraClipboard'; +import { shortTodayAwareFormat, todayAwareFormat } from '../../core/TimeUtils'; +import dayjs from 'dayjs'; + +const EmptyPlay = () => { + return ( + + + + + No Play object was provided. + + + + ); +} + +export interface PlayInfoProps { + play?: JsonPlayObject + final?: JsonPlayObject + showCopy?: boolean + showCompare?: boolean + showDates?: false | 'all' | 'played' | 'seen' +} + +export const PlayInfo = (props?: PlayInfoProps) => { + const { + play, + final, + showCopy = true, + showCompare = true, + showDates = 'all' + } = props ?? {}; + + if (play === undefined) { + return + } + + const [compareVal, setCompareVal] = useState('Original') + + const displayedPlay = compareVal === 'Original' ? play : final; + + let comparer: JSX.Element | null = null, + copy: JSX.Element | null = null; + + if (showCompare && final !== undefined) { + comparer = ( + setCompareVal(e.value)}> + + + + ); + } + if (showCopy) { + copy = ; + } + + const dates = useMemo(() => { + let dateElm: JSX.Element; + if (showDates !== false) { + let playDate: JSX.Element, + seenDate: JSX.Element; + if (play.data.playDate !== undefined && ['all','played'].includes(showDates)) { + playDate = {`Played ${shortTodayAwareFormat(dayjs(play.data.playDate))}`} + } + // TODO implement seenAt for play data + if (play.data.playDateCompleted !== undefined && ['all','seen'].includes(showDates)) { + seenDate = {`Seen ${shortTodayAwareFormat(dayjs(play.data.playDateCompleted))}`} + } + if (playDate !== undefined && seenDate !== undefined) { + dateElm = {playDate}{seenDate} + } else if (playDate !== undefined) { + dateElm = playDate; + } else if (seenDate !== undefined) { + dateElm = seenDate; + } + } + return dateElm; + }, [play, showDates]); + + let albumArtistElm: JSX.Element; + + if (displayedPlay.data.albumArtists !== undefined && displayedPlay.data.albumArtists.length > 0) { + albumArtistElm = ( + + Album Artists + + {displayedPlay.data.albumArtists.map((x, index) => { + return ( + + {x} + + ); + })} + + + ); + } + + const { + data: { + artists = [] + } = {} + } = displayedPlay; + + return ( + + + + {comparer}{copy} + + + + Title + {displayedPlay.data.track} + + + Artists + + {artists.length === 0 ? (No Artists) : + {displayedPlay.data.artists.map((x, index) => { + return ( + + {x} + + ); + })} } + + + + Album + {displayedPlay.data.album} + + + {dates} + + + ) +} \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index ad77b06d..935c608c 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -569,4 +569,7 @@ export const KNOWN_MEDIA_PROVIDER_URLS = [ /** Number of SECONDS since 1970 */ export type UnixTimestamp = number; -export type Writeable = { -readonly [P in keyof T]: T[P] }; \ No newline at end of file +export type Writeable = { -readonly [P in keyof T]: T[P] }; + +export const SHORT_CALENDAR_NOTZ_FORMAT = 'MMM D HH:mm:ss'; +export const SHORT_TODAY_NOTZ_FORMAT = 'HH:mm:ss'; \ No newline at end of file diff --git a/src/core/StringUtils.ts b/src/core/StringUtils.ts index f23bb95b..0743e8aa 100644 --- a/src/core/StringUtils.ts +++ b/src/core/StringUtils.ts @@ -287,4 +287,6 @@ export const arrayListAnd = (list: string[], joiner: string, finalJoiner: string const finalProper = spaced ? ` ${finalJoiner} ` : finalJoiner; return [start.join(joinerProper), end].join(finalProper); -} \ No newline at end of file +} + +export const safeStringify = (json: unknown) => JSON.stringify(json, null, 2); \ No newline at end of file diff --git a/src/core/TimeUtils.ts b/src/core/TimeUtils.ts new file mode 100644 index 00000000..7ff9e57d --- /dev/null +++ b/src/core/TimeUtils.ts @@ -0,0 +1,16 @@ +import dayjs, { Dayjs } from "dayjs"; +import isToday from 'dayjs/plugin/isToday.js'; +import { SHORT_CALENDAR_NOTZ_FORMAT, SHORT_TODAY_NOTZ_FORMAT } from "./Atomic.js"; + +dayjs.extend(isToday); + +export const todayAwareFormat = (date: Dayjs, opts: { fullFormat?: string; todayFormat?: string; } = {}): string => { + const { + fullFormat, todayFormat = 'HH:mm:ssZ' + } = opts; + return date.format(date.isToday() ? todayFormat : fullFormat); +}; + +export const shortTodayAwareFormat = (date: Dayjs): string => { + return todayAwareFormat(date, {fullFormat: SHORT_CALENDAR_NOTZ_FORMAT, todayFormat: SHORT_TODAY_NOTZ_FORMAT}); +} \ No newline at end of file diff --git a/src/stories/PlayInfo.stories.tsx b/src/stories/PlayInfo.stories.tsx new file mode 100644 index 00000000..22f0a63a --- /dev/null +++ b/src/stories/PlayInfo.stories.tsx @@ -0,0 +1,38 @@ +import preview from "../../.storybook/preview.js"; +import React from 'react'; + +import { fn } from 'storybook/test'; +import { PlayInfo } from "../client/components/PlayInfo"; +import {Provider} from "../client/components/Provider"; +import { generateJsonPlay, generatePlay } from "../backend/tests/utils/PlayTestUtils" +import clone from "clone"; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = preview.meta({ + title: 'Examples/PlayInfo', + component: PlayInfo, + 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'], +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 +}); + +const orig = generateJsonPlay(); +orig.data.playDateCompleted = orig.data.playDate; +const final = clone(orig); +final.data.track = `${final.data.track} (Album Version)`; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const PlayInfoStory = meta.story({ + args: { + play: orig, + final + }, + //render: function Render(args) { return () } +}); \ No newline at end of file diff --git a/src/stories/tsconfig.json b/src/stories/tsconfig.json index ec979f54..c68a02f0 100644 --- a/src/stories/tsconfig.json +++ b/src/stories/tsconfig.json @@ -19,7 +19,8 @@ "include": [ "*.stories.ts", "*.tsx", - "..src/client/**/*.tsx" + "..src/client/**/*.tsx", + "..src/backend/tests/**/*.ts" ], "exclude": [ "node_modules" -- 2.51.2