import React, { type ComponentProps, useMemo, useState, useEffect } from "react" import { Em, Span, Stack, Spacer, Text, Image, Box, Heading, HStack, Flex, Center, Container, Status } from '@chakra-ui/react'; import { TextMuted } from "../TextMuted"; import { SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson } from "../../../core/Atomic"; import { timeToHumanTimestamp } from "../../../core/TimeUtils"; import { capitalize } from "../../../core/StringUtils"; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ErrorAlert } from "../ErrorAlert"; import { useSSEAnyEvent, useSSEContext, useSSEEvent, } from "@flamefrontend/sse-runtime-react"; import type {ComponentCommonApiJson, MsSseEvent, MsSseEventPayload} from "../../../core/Api"; import LinearProgress from '@mui/material/LinearProgress'; import { InfoTip } from "../ToggleTip"; import { tanQueries } from "../../queries"; import dayjs from "dayjs"; import { MSErrorBoundary } from "../ErrorBoundary"; import { PlaybackReportingPlayer } from "../icons/PlaybackReporting"; export interface PlayerProps { data: SourcePlayerJson & {expiration?: string} nowPlaying?: boolean sot?: SOURCE_SOT_TYPES playbackReporting?: boolean } const bufferExplanation = (<> The reported bar is the real, reported position by the upstream service. The buffer bar is the calculated real-time position. This is reflected in the position timestamp and corrected when the reported position is updated. ); export const ChakraPlayer = (props: PlayerProps) => { const { data, nowPlaying = false, sot = SOURCE_SOT.PLAYER, playbackReporting } = props; const { play: { data: { track = '???', artists = [{ name: '???' }], duration = 0 } = {}, meta: { art = {}, } = {} } = {}, play, listenedDuration = 0, nowPlayingMode = false, status: { calculated = '???', reported, stale, orphaned } = {}, expiration } = data; const [positionBuffer, setProgressBuffer] = useState(undefined); const [intervalId, setIntervalId] = useState(undefined); const [lastUpdated, setLastUpdated] = useState(undefined); const playArt = art.track ?? art.album ?? art.artist ?? undefined; const isNowPlaying = nowPlaying || nowPlayingMode; let durPer = null; if (!isNowPlaying) { if (duration !== undefined && duration !== null && duration !== 0) { if (listenedDuration === 0 || listenedDuration === null) { durPer = ' (0%)'; } else { durPer = ` (${((listenedDuration / duration) * 100).toFixed(0)}%)`; } } } // this effect block is used to set the buffer progress using a realtime counter (setinterval) // we only set this as "active" if the player has reported it is calculated playing and we have position // -- then we update the buffer position to increment one second every second // this is reset to undefined if the valid player state changes to invalid (not playing) // or reset to the player position if props change but state stays valid (last reported position from upstream service) useEffect(() => { // abusing useState a little bit here... // need to clear interval on the old id if props have changed // but cannot use interval id in useEffect or it causes circular dependencies since we set intervalId here too // so clear inside the set state function (bad) using the previous data argument, before returning new value let interval; if(!isNowPlaying && data.status?.calculated === 'playing' && data.position !== undefined && !data.status?.stale && !data.status?.orphaned) { // eslint-disable-next-line react-hooks/set-state-in-effect setProgressBuffer(data.position); interval = setInterval(() => { setProgressBuffer((oldPosition) => { if(data.play.data.duration !== undefined) { return Math.min(data.play.data.duration, oldPosition + 1); } return oldPosition + 1; }); }, 1000); setIntervalId((old) => { if(old !== undefined) { clearInterval(old); } return interval; }); } else if(isNowPlaying) { interval = setInterval(() => { // force now playing-only player to re-render // so we can stop rendering it if it passes expiration date setLastUpdated(dayjs().unix()); }, 1000); setIntervalId((old) => { if(old !== undefined) { clearInterval(old); } return interval; }); } else { setProgressBuffer(undefined); if(intervalId !== undefined) { setIntervalId((old) => { if(old !== undefined) { clearInterval(old); } return undefined; }); } } return () => clearInterval(interval); },[setProgressBuffer, data, setIntervalId, setLastUpdated, isNowPlaying]); if(expiration !== undefined && dayjs().isAfter(dayjs(expiration))) { return null; } const indeterminate = isNowPlaying || (calculated === 'playing' && data.position === undefined); const positionProgress = indeterminate || data.position === undefined || duration === undefined ? undefined : Math.trunc((data.position/duration) * 100); const bufferProgress = indeterminate || data.position === undefined || duration === undefined || positionBuffer === undefined ? undefined : Math.trunc((positionBuffer/duration) * 100); const positionTimestamp = indeterminate || data.position === undefined ? '-' : timeToHumanTimestamp((positionBuffer ?? data.position) * 1000); const durationTimestamp = duration === undefined ? '-' : timeToHumanTimestamp(duration * 1000); const bufferTip = positionBuffer !== undefined ? : null; let playbackReportingTip: React.JSX.Element; if(playbackReporting !== undefined) { playbackReportingTip = } return ( {playArt !== undefined ? : null}
{calculated !== 'stopped' ? track : '-'} {calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'}
{positionTimestamp} {durationTimestamp} {/* {positionTimestamp} {durationTimestamp} */} {['unknown', 'playing'].includes(calculated) && isNowPlaying ? 'Now Playing' : capitalize(calculated)}{!isNowPlaying ? bufferTip : null}{playbackReportingTip} Listened: {isNowPlaying !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}
) } export const ClientNowPlaying = (props: PlayerProps) => { const { data: { play: { data: { track = '???', artists = [{ name: '???' }], duration = 0 } = {}, meta: { art = {}, } = {}, } = {}, expiration } = {} } = props; if(expiration !== undefined && dayjs().isAfter(dayjs(expiration))) { return null; } return ( Now Playing {artists.map(x => x.name).join('/')} - {track} ) } export interface ChakraPlayerFetchableProps { componentId: number nowPlaying?: boolean platformId: string data?: SourcePlayerJson sot?: SOURCE_SOT_TYPES } export const ChakraPlayerFetchable = (props: ChakraPlayerFetchableProps) => { const { componentId, nowPlaying, platformId, data: initData, sot } = props; const { isPending, isError, data, error } = usePlayerQuery(componentId, platformId, {initData}); if (isError) { return } if (!isPending) { if(nowPlaying) { return } return } } type UsePlayerQueryOpts = { initData?: SourcePlayerJson } export const usePlayerQuery = (componentId: number, platformId: string, opts: UsePlayerQueryOpts = {}) => { const { initData } = opts; const queryClient = useQueryClient(); useEffect(() => { if (initData !== undefined && queryClient.getQueryData(tanQueries.players.single(componentId, platformId).queryKey) === undefined) { queryClient.setQueryData(tanQueries.players.single(componentId, platformId).queryKey, initData); } }, [initData, componentId, platformId]); const client = useSSEContext>>(); useSSEEvent(client, "playerUpdate", (payload) => { if (payload.componentId === componentId && payload.data.platformId === platformId) { queryClient.setQueryData(tanQueries.players.single(componentId, platformId).queryKey, payload.data); } }); const { isPending, isError, data, error } = useQuery({ ...tanQueries.players.single(componentId, platformId), staleTime: Infinity, }); return { data, isPending, isError, error }; } export const PlayersContainer = (props: { data: ComponentCommonApiJson, live?: boolean, nowPlaying?: boolean, stack?: ComponentProps, container?: ComponentProps }) => { const { data, nowPlaying, live, container = {}, stack = {} } = props; const { players = {} } = data; const playerContainers: React.JSX.Element[] = []; // const isSource = isComponentSourceApiJson(data); // const now = dayjs(); if (Object.keys(players).length > 0) { for(const [key, x] of Object.entries(players)) { // if(!isSource && 'expiration' in x) { // const expiresAt = dayjs(x.expiration as string); // if(now.isAfter(expiresAt)) { // continue; // } // } playerContainers.push( live ? : (nowPlaying ? : ) ); }; } // if(playerContainers.length > 0) { // return // {playerContainers} // ; // } // return null; return {playerContainers} ; } const usePlayersQuery = (componentId: number, players?: ComponentCommonApiJson['players']) => { const queryClient = useQueryClient(); useEffect(() => { if(queryClient.getQueryData(tanQueries.players.list(componentId).queryKey) === undefined) { queryClient.setQueryData(tanQueries.players.list(componentId).queryKey, players ?? {}); } }, [players, componentId]); const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { if('componentId' in (payload.data as object) && (payload.data as Record).componentId === componentId) { switch(payload.type) { case 'playerUpdate': { const playerPayload = payload.data as MsSseEventPayload; queryClient.setQueryData(tanQueries.players.list(componentId).queryKey, (old: Record) => { if(old[playerPayload.data.platformId] === undefined || 'expiration' in playerPayload.data) { const newData: Record = {...old}; newData[playerPayload.data.platformId] = playerPayload.data; return newData; } }); } break; case 'playerDelete':{ const playerPayload = payload.data as MsSseEventPayload<{platformId: string}>; queryClient.setQueryData(tanQueries.players.list(componentId).queryKey, (old: Record) => { if(old[playerPayload.data.platformId] !== undefined) { const newData: Record = {...old}; delete newData[playerPayload.data.platformId]; return newData; } }); } break; } } }); const { isPending, isError, data = {}, error } = useQuery({ ...tanQueries.players.list(componentId), staleTime: Infinity, }); return {isPending, isError, data, error}; } export const PlayersContainerFetchable = (props: { data: ComponentCommonApiJson, live?: boolean, nowPlaying?: boolean, stack?: ComponentProps, container?: ComponentProps }) => { const { data: initData, nowPlaying, live = true, container = {}, stack = {} } = props; const { isPending, isError, data = {}, error } = usePlayersQuery(initData.id, initData.players); const mergedData = useMemo(() => ({...initData, players: data}),[initData,data]); return }