init: inital commit

This commit is contained in:
2025-02-20 12:39:27 +01:00
commit 3c77360c9d
38 changed files with 8525 additions and 0 deletions
+66
View File
@@ -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"
/>
</>
);
}
+6
View File
@@ -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 />);
+19
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.build.json",
"include": ["**/*.tsx", "**/*.ts", "../types/**/*.ts", "../../node_modules/@nodecg/types/augment-window.d.ts"]
}
+151
View File
@@ -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", () => {});
};
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.build.json",
"include": ["**/*.ts", "../types/schemas/index.d.ts"]
}
+266
View File
@@ -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>
</>
);
}
+90
View File
@@ -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>
</>
);
}
+6
View File
@@ -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 />);
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+12
View File
@@ -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>
+6
View File
@@ -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 />);
+11
View File
@@ -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>
+24
View File
@@ -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]);
};
+3
View File
@@ -0,0 +1,3 @@
html {
font-size: 1vw;
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.build.json",
"include": ["**/*.tsx", "**/*.ts", "../types/**/*.ts", "../../node_modules/@nodecg/types/augment-window.d.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./musicReplicant";
export * from "./mpdConfigReplicant";
+4
View File
@@ -0,0 +1,4 @@
export interface MpdConfigReplicant {
port: number;
musicDirectory: string;
}
+18
View File
@@ -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
View File
@@ -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';
}