init: inital commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import type NodeCG from "@nodecg/types";
|
||||
import type { MpdConfigReplicant, MusicConfigReplicant } from "../types";
|
||||
|
||||
const mpdConfigReplicant = nodecg.Replicant(
|
||||
"mpd-config",
|
||||
) as unknown as NodeCG.ServerReplicantWithSchemaDefault<MpdConfigReplicant>;
|
||||
|
||||
const musicConfigReplicant = nodecg.Replicant(
|
||||
"musicConfigReplicant",
|
||||
) as unknown as NodeCG.ServerReplicant<MusicConfigReplicant>;
|
||||
|
||||
export function Panel() {
|
||||
const [port, _setPort] = useState(6600);
|
||||
const [musicDirectory, _setMusicDir] = useState("~/Music");
|
||||
const [tickerChangeInterval, _setTickerChangeInterval] = useState(25);
|
||||
|
||||
const setPort = (port: number) => {
|
||||
_setPort(port);
|
||||
mpdConfigReplicant.value.port = port;
|
||||
};
|
||||
|
||||
const setMusicDir = (musicDir: string) => {
|
||||
_setMusicDir(musicDir);
|
||||
mpdConfigReplicant.value.musicDirectory = musicDir;
|
||||
};
|
||||
|
||||
const setTickerChangeInterval = (int: number) => {
|
||||
_setTickerChangeInterval(int);
|
||||
musicConfigReplicant.value!.tickerSwapInterval = int;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>MPD connection port</div>
|
||||
<input
|
||||
value={port}
|
||||
onChange={(e) => setPort(parseInt(e.target.value))}
|
||||
id="port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65565"
|
||||
/>
|
||||
|
||||
<div>MPD music directory</div>
|
||||
<input
|
||||
value={musicDirectory}
|
||||
onChange={(e) => setMusicDir(e.target.value)}
|
||||
id="musicdir"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<div>Ticker change interval (in seconds)</div>
|
||||
<input
|
||||
value={tickerChangeInterval}
|
||||
onChange={(e) => setTickerChangeInterval(parseInt(e.target.value))}
|
||||
id="interval"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1800"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Panel } from './Panel';
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(<Panel />);
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./panel-bootstrap.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.build.json",
|
||||
"include": ["**/*.tsx", "**/*.ts", "../types/**/*.ts", "../../node_modules/@nodecg/types/augment-window.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { homedir } from "os";
|
||||
|
||||
export default function untildify(pathWithTilde: string) {
|
||||
if (typeof pathWithTilde !== "string") {
|
||||
throw new TypeError(`Expected a string, got ${typeof pathWithTilde}`);
|
||||
}
|
||||
|
||||
return homedir()
|
||||
? pathWithTilde.replace(/^~(?=$|\/|\\)/, homedir())
|
||||
: pathWithTilde;
|
||||
}
|
||||
|
||||
import type NodeCG from "@nodecg/types";
|
||||
import type {
|
||||
CoverArtReplicant,
|
||||
MpdConfigReplicant,
|
||||
MusicConfigReplicant,
|
||||
MusicReplicant,
|
||||
} from "../types";
|
||||
|
||||
import { loadMusicMetadata } from "music-metadata";
|
||||
import { MPC } from "mpc-js";
|
||||
|
||||
module.exports = async function (nodecg: NodeCG.ServerAPI) {
|
||||
const mm = await loadMusicMetadata();
|
||||
|
||||
const musicReplicant = nodecg.Replicant(
|
||||
"musicReplicant",
|
||||
) as unknown as NodeCG.ServerReplicantWithSchemaDefault<MusicReplicant>;
|
||||
|
||||
const musicConfigReplicant = nodecg.Replicant(
|
||||
"musicConfigReplicant",
|
||||
) as unknown as NodeCG.ServerReplicant<MusicConfigReplicant>;
|
||||
|
||||
const mpdConfigReplicant = nodecg.Replicant(
|
||||
"mpd-config",
|
||||
) as unknown as NodeCG.ServerReplicantWithSchemaDefault<MpdConfigReplicant>;
|
||||
|
||||
const coverArtReplicant = nodecg.Replicant(
|
||||
"coverArtReplicant",
|
||||
) as unknown as NodeCG.ServerReplicant<CoverArtReplicant>;
|
||||
|
||||
const mpc = new MPC();
|
||||
|
||||
const connect = async (newValue: MpdConfigReplicant) => {
|
||||
try {
|
||||
mpc.disconnect();
|
||||
} catch {}
|
||||
// Possibly not even connected
|
||||
|
||||
try {
|
||||
await mpc.connectTCP("localhost", newValue.port);
|
||||
return true;
|
||||
} catch (e) {
|
||||
nodecg.log.error("MPD: failed to connect", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
mpdConfigReplicant.on("change", connect);
|
||||
|
||||
// Default values
|
||||
mpdConfigReplicant.value = {
|
||||
port: 6600,
|
||||
musicDirectory: "~/Music",
|
||||
};
|
||||
|
||||
musicConfigReplicant.value = {
|
||||
tickerSwapInterval: 25,
|
||||
};
|
||||
|
||||
mpc.on("ready", () => {
|
||||
grabSongInfo();
|
||||
});
|
||||
|
||||
mpc.on("socket-end", () => {
|
||||
let i = 1;
|
||||
const interval = setInterval(async () => {
|
||||
nodecg.log.warn(`MPD: attempt #${i++} to reconnect`);
|
||||
const success = await connect(mpdConfigReplicant.value!);
|
||||
if (success) clearInterval(interval);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
const grabSongInfo = async () => {
|
||||
try {
|
||||
const status = await mpc.status.status();
|
||||
|
||||
if (status.state !== "play") {
|
||||
musicReplicant.value.playing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSong = await mpc.status.currentSong();
|
||||
|
||||
// We've just paused and unpaused: don't push an unnecessary update
|
||||
if (
|
||||
currentSong.album === musicReplicant.value.album &&
|
||||
currentSong.title === musicReplicant.value.name &&
|
||||
currentSong.artist === musicReplicant.value.artist
|
||||
) {
|
||||
musicReplicant.value.playing = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let cover;
|
||||
let comment;
|
||||
|
||||
try {
|
||||
const songPath = `${mpdConfigReplicant.value.musicDirectory}/${currentSong.path!}`;
|
||||
const metadata = await mm.parseFile(untildify(songPath));
|
||||
|
||||
comment = metadata.common.comment?.map((c) => c.text).join("\n");
|
||||
|
||||
const picture = metadata.common.picture?.[0];
|
||||
if (picture) {
|
||||
cover = { data: picture.data, format: picture.format };
|
||||
} else {
|
||||
nodecg.log.warn(
|
||||
"MPD: no cover art associated with file",
|
||||
currentSong,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
nodecg.log.error("MPD: failed metadata parse", e);
|
||||
}
|
||||
|
||||
musicReplicant.value = {
|
||||
playing: true,
|
||||
artist: currentSong.artist?.replaceAll(";", ", "),
|
||||
album: currentSong.album,
|
||||
name: currentSong.title,
|
||||
year: currentSong.date,
|
||||
duration: currentSong.duration,
|
||||
comment,
|
||||
};
|
||||
|
||||
if (cover !== undefined) {
|
||||
coverArtReplicant.value = cover;
|
||||
}
|
||||
} catch (e) {
|
||||
nodecg.log.error("MPD: error during grabbing current song", e);
|
||||
}
|
||||
};
|
||||
|
||||
// MPD sends `player` events when the playing song changes or stops
|
||||
mpc.on("changed-player", grabSongInfo);
|
||||
|
||||
mpc.on("a", () => {});
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.build.json",
|
||||
"include": ["**/*.ts", "../types/schemas/index.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Flipper, Flipped } from "react-flip-toolkit";
|
||||
|
||||
import { useReplicant } from "./lib";
|
||||
|
||||
import type NodeCG from "@nodecg/types";
|
||||
import type { MusicConfigReplicant, MusicReplicant } from "../types";
|
||||
|
||||
const musicReplicant = nodecg.Replicant(
|
||||
"musicReplicant",
|
||||
) as unknown as NodeCG.ServerReplicant<MusicReplicant>;
|
||||
|
||||
const musicConfigReplicant = nodecg.Replicant(
|
||||
"musicConfigReplicant",
|
||||
) as unknown as NodeCG.ServerReplicant<MusicConfigReplicant>;
|
||||
|
||||
const kReadingWpm = 238;
|
||||
|
||||
export function Info() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [songInfo, setSongInfo] = useState(
|
||||
{} as Omit<MusicReplicant, "playing">,
|
||||
);
|
||||
|
||||
useReplicant(musicReplicant, (newValue?: MusicReplicant) => {
|
||||
setIsPlaying(newValue?.playing || false);
|
||||
setSongInfo(newValue || {});
|
||||
});
|
||||
|
||||
const readingTime =
|
||||
((songInfo.comment?.split(" ").filter((x) => x).length || 0) /
|
||||
kReadingWpm) *
|
||||
60;
|
||||
|
||||
const pauseLength = Math.max(
|
||||
0,
|
||||
Math.min(((songInfo.duration || 0) - 2 * 2 * readingTime) / 3, 120),
|
||||
);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
let timeout: undefined | NodeJS.Timeout;
|
||||
|
||||
const waitThenShow = () => {
|
||||
timeout = setTimeout(() => {
|
||||
setVisible(true);
|
||||
showThenWait();
|
||||
}, pauseLength * 1000);
|
||||
};
|
||||
|
||||
const showThenWait = () => {
|
||||
timeout = setTimeout(() => {
|
||||
setVisible(false);
|
||||
waitThenShow();
|
||||
}, readingTime * 2000);
|
||||
};
|
||||
|
||||
waitThenShow();
|
||||
|
||||
return () => {
|
||||
setVisible(false);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [songInfo, isPlaying]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="origin-top transition duration-280"
|
||||
style={{
|
||||
transform: visible ? " scaleY(1) " : " scaleY(0) ",
|
||||
}}
|
||||
>
|
||||
<div className="bg-black text-3xl text-white font-bold p-3 px-5">
|
||||
Da li ste znali?
|
||||
</div>
|
||||
<div
|
||||
lang="sr"
|
||||
className="bg-[#00a63e] text-2xl text-white whitespace-pre-wrap p-5 pt-4 pb-6 text-justify hyphens-auto transition duration-280"
|
||||
style={{}}
|
||||
>
|
||||
<div>{songInfo?.comment}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Index } from "./Index";
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<Index />);
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="./index.css" type="text/css" rel="stylesheet" />
|
||||
<link href="./rem-vw.css" type="text/css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index-bootstrap.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Info } from "./Info";
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<Info />);
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="./index.css" type="text/css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./info-bootstrap.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
import NodeCG from "@nodecg/types";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export 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]);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
html {
|
||||
font-size: 1vw;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.build.json",
|
||||
"include": ["**/*.tsx", "**/*.ts", "../types/**/*.ts", "../../node_modules/@nodecg/types/augment-window.d.ts"]
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./musicReplicant";
|
||||
export * from "./mpdConfigReplicant";
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export interface MpdConfigReplicant {
|
||||
port: number;
|
||||
musicDirectory: string;
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
export interface MusicReplicant {
|
||||
playing: boolean;
|
||||
name?: string;
|
||||
album?: string;
|
||||
artist?: string;
|
||||
year?: string;
|
||||
comment?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface CoverArtReplicant {
|
||||
data?: UInt8Array;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface MusicConfigReplicant {
|
||||
tickerSwapInterval: number;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/* prettier-ignore */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
export interface ExampleReplicant {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
/**
|
||||
* Age in years
|
||||
*/
|
||||
age: number;
|
||||
hairColor?: 'black' | 'brown' | 'blue';
|
||||
}
|
||||
Reference in New Issue
Block a user