267 lines
8.4 KiB
TypeScript
267 lines
8.4 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { Flipper, Flipped } from "react-flip-toolkit";
|
|
|
|
import { RiMusic2Line, RiUser3Line, RiAlbumLine } from "@remixicon/react";
|
|
|
|
import type NodeCG from "@nodecg/types";
|
|
import type {
|
|
CoverArtReplicant,
|
|
MusicConfigReplicant,
|
|
MusicReplicant,
|
|
} from "../types";
|
|
|
|
const ensureArrayBufferLike = (data: any): ArrayBufferLike => {
|
|
if (data instanceof Uint8Array || data instanceof ArrayBuffer) {
|
|
return data.slice();
|
|
} else if (Array.isArray(data)) {
|
|
return new Uint8Array(data);
|
|
} else if (typeof data === "object" && data !== null) {
|
|
return new Uint8Array(Object.values(data));
|
|
}
|
|
throw new Error("Unsupported data format");
|
|
};
|
|
|
|
const musicReplicant = nodecg.Replicant(
|
|
"musicReplicant",
|
|
) as unknown as NodeCG.ServerReplicant<MusicReplicant>;
|
|
|
|
const musicConfigReplicant = nodecg.Replicant(
|
|
"musicConfigReplicant",
|
|
) as unknown as NodeCG.ServerReplicant<MusicConfigReplicant>;
|
|
|
|
const coverArtReplicant = nodecg.Replicant(
|
|
"coverArtReplicant",
|
|
) as unknown as NodeCG.ServerReplicant<CoverArtReplicant>;
|
|
const useReplicant = <T = any,>(
|
|
replicant: NodeCG.ServerReplicant<T>,
|
|
fn: (newValue: T | undefined, oldValue: T | undefined) => void | (() => void),
|
|
deps: any[] = [],
|
|
) => {
|
|
useEffect(() => {
|
|
let cleanup: (() => void) | void;
|
|
|
|
const handler = (newValue: T | undefined, oldValue: T | undefined) => {
|
|
if (cleanup) cleanup(); // Run previous cleanup before setting new one
|
|
cleanup = fn(newValue, oldValue); // Store new cleanup function (if provided)
|
|
};
|
|
|
|
replicant.on("change", handler);
|
|
|
|
return () => {
|
|
replicant.off("change", handler);
|
|
if (cleanup) cleanup();
|
|
};
|
|
}, [...deps]);
|
|
};
|
|
|
|
export function Index() {
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [songInfo, setSongInfo] = useState(
|
|
{} as Omit<MusicReplicant, "playing">,
|
|
);
|
|
|
|
useReplicant(musicReplicant, (newValue?: MusicReplicant) => {
|
|
setIsPlaying(newValue?.playing || false);
|
|
setSongInfo(newValue || {});
|
|
});
|
|
|
|
const [imageUrl, setImageUrl] = useState(null as null | string);
|
|
|
|
useReplicant(
|
|
coverArtReplicant,
|
|
(newValue?: CoverArtReplicant) => {
|
|
if (newValue?.data && newValue?.format) {
|
|
const data = ensureArrayBufferLike(newValue.data);
|
|
|
|
// Convert to Blob
|
|
const blob = new Blob([data], {
|
|
type: newValue.format,
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
setImageUrl(url);
|
|
|
|
// Free old image
|
|
return () => {
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const [songAlbumToggle, setSongAlbumToggle] = useState(true);
|
|
const [showLabelNames, setShowLabelNames] = useState(true);
|
|
|
|
const mainTickerClasses =
|
|
" bg-black text-white line-clamp-3 min-h-[calc(var(--height)_*_1/3)] py-8 text-8xl ";
|
|
const subTickerClasses =
|
|
" min-h-[calc(var(--height)_*_1/5)] bg-[#fdc700] line-clamp-2 py-4 text-5xl ";
|
|
|
|
const topTickerClasses = songAlbumToggle
|
|
? mainTickerClasses
|
|
: subTickerClasses;
|
|
const bottomTickerClasses = !songAlbumToggle
|
|
? mainTickerClasses
|
|
: subTickerClasses;
|
|
|
|
const [tickerSwapInterval, setTickerSwapInterval] = useState(25000);
|
|
|
|
useReplicant(
|
|
musicConfigReplicant,
|
|
(newValue) => {
|
|
setTickerSwapInterval(newValue!.tickerSwapInterval * 1000);
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let value = songAlbumToggle;
|
|
|
|
let timeout1 = undefined as undefined | NodeJS.Timeout;
|
|
let timeout2 = undefined as undefined | NodeJS.Timeout;
|
|
|
|
const interval = setInterval(() => {
|
|
value = !value;
|
|
setSongAlbumToggle(value);
|
|
|
|
timeout1 = setTimeout(() => {
|
|
setShowLabelNames(true);
|
|
timeout2 = setTimeout(
|
|
() => {
|
|
setShowLabelNames(false);
|
|
},
|
|
Math.min(tickerSwapInterval * 0.5, 10000),
|
|
);
|
|
}, tickerSwapInterval / 5);
|
|
}, tickerSwapInterval);
|
|
|
|
return () => {
|
|
setSongAlbumToggle(true);
|
|
setShowLabelNames(false);
|
|
|
|
clearInterval(interval);
|
|
clearTimeout(timeout1);
|
|
clearTimeout(timeout2);
|
|
};
|
|
}, [tickerSwapInterval, songInfo]);
|
|
|
|
const springOpts = "stiff";
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="p-16 overflow-hidden drop-shadow-2xl"
|
|
style={{
|
|
display: "flex",
|
|
...{ "--height": "26vw" },
|
|
}}
|
|
>
|
|
<div className={"flex flex-col relative "}>
|
|
{isPlaying ? (
|
|
<div
|
|
className={
|
|
"flex bg-[#00a63e] text-3xl p-3 text-white relative transition "
|
|
}
|
|
style={{
|
|
transform: `translateY(${songAlbumToggle ? 100 : 0}%)`,
|
|
}}
|
|
>
|
|
<div className="">Godina izdavanja:</div>
|
|
<div className="grow" />
|
|
<div className="font-bold">{songInfo.year}</div>
|
|
</div>
|
|
) : (
|
|
<></>
|
|
)}
|
|
|
|
{imageUrl && isPlaying ? (
|
|
<img
|
|
className={"z-10"}
|
|
src={imageUrl}
|
|
alt="Cover art"
|
|
style={{
|
|
width: "var(--height)",
|
|
height: "var(--height)",
|
|
minWidth: "var(--height)",
|
|
minHeight: "var(--height)",
|
|
}}
|
|
/>
|
|
) : (
|
|
<></>
|
|
)}
|
|
</div>
|
|
{isPlaying ? (
|
|
<div className="block relative flex flex-col">
|
|
<div className="grow" />
|
|
|
|
<Flipper
|
|
flipKey={
|
|
songAlbumToggle + "," + showLabelNames + "," + songInfo.name
|
|
}
|
|
spring={springOpts}
|
|
>
|
|
<Flipped flipId="tickers">
|
|
<div
|
|
className={
|
|
"inline-flex relative z-30 overflow-visible shrink-0 transition-colors duration-100 flex-col justify-center min-w-(--height) px-8 font-bold " +
|
|
topTickerClasses +
|
|
"" ||
|
|
(showLabelNames && !songAlbumToggle ? " pb-12 pt-8 " : "")
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
"absolute flex items-center justify-center transition origin-left overflow-hidden min-h-[calc(var(--height)_/_8)] min-w-[calc(var(--height)_/_8)] text-2xl top-[calc(100%_-_var(--height)_/_10.66)] right-[calc(var(--height)_/_-10.66)] font-bold bg-[#00a63e] h-[var(--height)_/_10.66] text-white top-4 z-20 ml-4 max-w-fit "
|
|
}
|
|
style={{
|
|
transform: showLabelNames ? " scaleX(1) " : " scaleX(0) ",
|
|
}}
|
|
>
|
|
{songAlbumToggle ? (
|
|
<RiMusic2Line color="white" size={"2.5rem"} />
|
|
) : (
|
|
<RiUser3Line color="white" size={"2.5rem"} />
|
|
)}
|
|
</div>
|
|
|
|
{songAlbumToggle ? songInfo.name : songInfo.artist}
|
|
</div>
|
|
</Flipped>
|
|
</Flipper>
|
|
<Flipper flipKey={songAlbumToggle} spring={springOpts}>
|
|
<Flipped flipId="tickers">
|
|
<div
|
|
className={
|
|
"inline-flex overflow-visible relative shrink-0 transition-colors duration-100 flex-col justify-center shrink-0 min-w-(--height) px-8 font-bold z-20 " +
|
|
bottomTickerClasses
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
"absolute flex items-center justify-center transition origin-left overflow-hidden min-h-[calc(var(--height)_/_8)] min-w-[calc(var(--height)_/_8)] text-2xl top-[calc(100%_-_var(--height)_/_10.66)] right-[calc(var(--height)_/_-10.66)] font-bold bg-[#00a63e] h-[var(--height)_/_10.66] text-white top-4 ml-4 max-w-fit "
|
|
}
|
|
style={{
|
|
transform: showLabelNames ? " scaleX(1) " : " scaleX(0) ",
|
|
}}
|
|
>
|
|
{songAlbumToggle ? (
|
|
<RiUser3Line color="white" size={"2.5rem"} />
|
|
) : (
|
|
<RiAlbumLine color="white" size={"2.5rem"} />
|
|
)}
|
|
</div>
|
|
|
|
{songAlbumToggle ? songInfo.artist : songInfo.album}
|
|
</div>
|
|
</Flipped>
|
|
</Flipper>
|
|
</div>
|
|
) : (
|
|
<></>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|