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
}