Initial commit

This commit is contained in:
2026-02-01 00:25:52 +00:00
commit c2ba9dbeea
18 changed files with 2461 additions and 0 deletions

67
src/index.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en-gb">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MuonRC</title>
<link rel="stylesheet" href="res/normalise.min.css">
<link rel="stylesheet" href="res/style.css">
</head>
<body>
<header>
<div class="hr"></div>
<h1 style="margin: 5px 0 0 0;">MuonRC</h1>
<sub>v0.1.0 - "a grand don't come for free" Edition</sub>
<nav>
<ul>
<li>
<a href="#details">Details</a>
</li>
<li>
<a href="#status">Status</a>
</li>
<li>
<a href="#playback">Playback</a>
</li>
</ul>
</nav>
</header>
<main>
<section id="details">
<h2>Server Details</h2>
<div class="flex-row">
<div>
<label for="host">Host</label>
<br>
<input type="text" name="host" id="host" placeholder="localhost">
</div>
</div>
<br>
<button type="submit" id="details-submit">Submit</button>
<button id="details-clear">Clear</button>
<p class="success" id="details-success">Connected successfully!</p>
<p class="error" id="details-error">Can't connect to specified hostname!</p>
</section>
<section id="status">
<h2>Status</h2>
<p>API Version: <span id="api-version"></span></p>
</section>
<section id="playback">
<h2>Playback</h2>
<div id="player">
<img src="" alt="Album art" id="album-art">
<p id="playback-song"></p>
<p id="playback-album"></p>
<p id="playback-artist"></p>
<div id="controls">
<input id="controls-play" type="image" src="res/img/play.svg" alt="Play">
<input id="controls-stop" type="image" src="res/img/stop.svg" alt="Stop">
<input id="controls-rewind" type="image" src="res/img/rewind.svg" alt="Last song">
<input id="controls-fastforward" type="image" src="res/img/fastforward.svg" alt="Next song">
</div>
</div>
</section>
</main>
<script type="module" src="res/muonrc.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M100-240v-480l360 240-360 240Zm400 0v-480l360 240-360 240ZM180-480Zm400 0Zm-400 90 136-90-136-90v180Zm400 0 136-90-136-90v180Z"/></svg>

After

Width:  |  Height:  |  Size: 251 B

1
src/res/img/play.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z"/></svg>

After

Width:  |  Height:  |  Size: 190 B

1
src/res/img/rewind.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M860-240 500-480l360-240v480Zm-400 0L100-480l360-240v480Zm-80-240Zm400 0Zm-400 90v-180l-136 90 136 90Zm400 0v-180l-136 90 136 90Z"/></svg>

After

Width:  |  Height:  |  Size: 254 B

1
src/res/img/stop.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#ffffff"><path d="M320-640v320-320Zm-80 400v-480h480v480H240Zm80-80h320v-320H320v320Z"/></svg>

After

Width:  |  Height:  |  Size: 192 B

159
src/res/muonrc.ts Normal file
View File

@@ -0,0 +1,159 @@
import { version, status, pic, play, pause, back, next } from "./routes.js"
interface sectionObj {
element: HTMLElement,
name: string
}
const sections: Array<sectionObj> = [
{
element: document.getElementById("details")!,
name: "details"
},
{
element: document.getElementById("status")!,
name: "status"
},
{
element: document.getElementById("playback")!,
name: "playback"
}
]
function updatePlayback(host: string) {
const art = document.getElementById("album-art") as HTMLImageElement
if (host === null) {
console.error("no host set!")
return
}
status(host)
.then(stat => {
document.getElementById("playback-song")!.innerText = stat.title
document.getElementById("playback-album")!.innerText = stat.album
document.getElementById("playback-artist")!.innerText = stat.artist
pic(host, stat.id, "medium")
.then(blob => {
art.src = URL.createObjectURL(blob)
})
})
}
function showView(sectionName: string) {
for (const section of sections) {
if (section.name === sectionName)
section.element.style.display = "block"
else
section.element.style.display = "none"
}
const cachedHost = localStorage.getItem("host")
switch (sectionName) {
case "status":
if (cachedHost === null) {
document.getElementById("api-version")!.innerText = "No host set!"
return
}
version(cachedHost)
.then(res => res.version)
.then(ver => document.getElementById("api-version")!.innerText = `${ver}`)
break
case "playback":
updatePlayback(cachedHost!)
break
}
}
function setupDefaultDetails() {
const host = document.getElementById("host") as HTMLInputElement
const cachedHost = localStorage.getItem("host")
if (cachedHost !== null) {
host.placeholder = cachedHost
host.disabled = true
} else {
host.disabled = false
}
}
function main() {
setupDefaultDetails()
if (window.location.hash !== "#details" && localStorage.getItem("host") === null)
window.location.hash = "#details"
else {
showView(window.location.hash.substring(1))
}
}
addEventListener("hashchange", (e) => {
const newURL = new URL(e.newURL)
showView(newURL.hash.substring(1))
})
function showDetailsErr() {
const error = document.getElementById("details-error") as HTMLParagraphElement
error.style.display = "block"
setTimeout(() => {
error.style.display = "none"
}, 2500);
}
function showDetailsSuccess() {
const success = document.getElementById("details-success") as HTMLParagraphElement
success.style.display = "block"
setTimeout(() => {
success.style.display = "none"
}, 2500);
}
document.getElementById("details-submit")!.addEventListener("click", () => {
const host = document.getElementById("host") as HTMLInputElement
console.log(host)
if (host.value === "")
return
localStorage.setItem("host", host.value)
version(host.value)
.then(() => {
console.log("success! saved")
host.disabled = true
showDetailsSuccess()
})
.catch(() => {
localStorage.removeItem("host")
host.disabled = false
showDetailsErr()
})
})
document.getElementById("details-clear")!.addEventListener("click", () => {
localStorage.removeItem("host")
setupDefaultDetails()
})
document.getElementById("controls-play")!.addEventListener("click", () => {
play(localStorage.getItem("host")!)
})
document.getElementById("controls-stop")!.addEventListener("click", () => {
pause(localStorage.getItem("host")!)
})
document.getElementById("controls-rewind")!.addEventListener("click", () => {
const host = localStorage.getItem("host")
next(host!)
setTimeout(() => {
updatePlayback(host!)
}, 50);
})
document.getElementById("controls-fastforward")!.addEventListener("click", () => {
const host = localStorage.getItem("host")!
back(host!)
setTimeout(() => {
updatePlayback(host!)
}, 50);
})
main()

2
src/res/normalise.min.css vendored Normal file
View File

@@ -0,0 +1,2 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}
/*# sourceMappingURL=normalize.min.css.map */

View File

@@ -0,0 +1 @@
{"version":3,"sources":["normalize.min.css"],"names":[],"mappings":"AAAA,4EAUA,KACE,YAAa,KACb,yBAA0B,KAU5B,KACE,OAAQ,EAOV,KACE,QAAS,MAQX,GACE,UAAW,IACX,OAAQ,MAAO,EAWjB,GACE,WAAY,YACZ,OAAQ,EACR,SAAU,QAQZ,IACE,YAAa,SAAS,CAAE,UACxB,UAAW,IAUb,EACE,iBAAkB,YAQpB,YACE,cAAe,KACf,gBAAiB,UACjB,gBAAiB,UAAU,OAO7B,EACA,OACE,YAAa,OAQf,KACA,IACA,KACE,YAAa,SAAS,CAAE,UACxB,UAAW,IAOb,MACE,UAAW,IAQb,IACA,IACE,UAAW,IACX,YAAa,EACb,SAAU,SACV,eAAgB,SAGlB,IACE,OAAQ,OAGV,IACE,IAAK,MAUP,IACE,aAAc,KAWhB,OACA,MACA,SACA,OACA,SACE,YAAa,QACb,UAAW,KACX,YAAa,KACb,OAAQ,EAQV,OACA,MACE,SAAU,QAQZ,OACA,OACE,eAAgB,KAQlB,cACA,aACA,cAHA,OAIE,mBAAoB,OAQtB,gCACA,+BACA,gCAHA,yBAIE,aAAc,KACd,QAAS,EAQX,6BACA,4BACA,6BAHA,sBAIE,QAAS,IAAI,OAAO,WAOtB,SACE,QAAS,MAAO,MAAO,OAUzB,OACE,WAAY,WACZ,MAAO,QACP,QAAS,MACT,UAAW,KACX,QAAS,EACT,YAAa,OAOf,SACE,eAAgB,SAOlB,SACE,SAAU,KAQZ,gBACA,aACE,WAAY,WACZ,QAAS,EAOX,yCACA,yCACE,OAAQ,KAQV,cACE,mBAAoB,UACpB,eAAgB,KAOlB,yCACE,mBAAoB,KAQtB,6BACE,mBAAoB,OACpB,KAAM,QAUR,QACE,QAAS,MAOX,QACE,QAAS,UAUX,SACE,QAAS,KAOX,SACE,QAAS"}

105
src/res/routes.ts Normal file
View File

@@ -0,0 +1,105 @@
export interface VersionRes {
version: number
}
export interface TrackRes {
title: string,
artist: string,
album: string,
album_artist: string,
duration: number,
id: number,
position: number,
album_id: number,
track_number: string,
can_download: boolean,
has_lyrics: boolean
}
export interface StatusRes {
status: "stopped" | "playing" | "paused",
inc: number,
shuffle: boolean,
repeat: boolean,
playlist: string,
playlist_length: number,
id: number,
title: string,
artist: string,
album: string,
progress: number,
auto_stop: boolean,
volume: number,
position: number,
track: TrackRes
}
export function version(host: string) {
return fetch(`http://${host}:7815/version?host=${host}`)
.then((res) => {
if (!res.ok)
throw new Error("Could not ping server. Is host correct?")
return res.json() as Promise<VersionRes>
})
}
export function status(host: string) {
return fetch(`http://${host}:7815/status?host=${host}`)
.then((res) => {
if (!res.ok)
throw new Error() // these get handled downstream ;)
return res.json() as Promise<StatusRes>
})
}
// export function pic(host: string, trackID: number | null, size: "small" | "medium") {
// if (trackID === null) {
// status(host)
// .then((res) => res.id)
// .then(id => {
// return fetch(`http://localhost:7815/pic?host=${host}&size=${size}&id=${id}`)
// .then((res) => {
// if (!res.ok)
// throw new Error()
// return res
// })
// })
// } else {
// return fetch(`http://localhost:7815/pic?host=${host}&size=${size}&id=${trackID}`)
// .then((res) => {
// if (!res.ok)
// throw new Error()
// return res
// })
// }
// }
export async function pic(host: string, trackID: number | null, size: "small" | "medium") {
if (trackID === null) {
const resStatus = await status(host)
const id = resStatus.id
trackID = id
}
const res = await fetch(`http://${host}:7815/pic?host=${host}&size=${size}&id=${trackID}`)
if (!res.ok) throw new Error("Fetch failed")
const blob = await res.blob()
return blob
}
export function play(host: string) {
fetch(`http://${host}:7815/play?host=${host}`)
}
export function pause(host: string) {
fetch(`http://${host}:7815/pause?host=${host}`)
}
export function next(host: string) {
fetch(`http://${host}:7815/next?host=${host}`)
}
export function back(host: string) {
fetch(`http://${host}:7815/back?host=${host}`)
}

162
src/res/style.css Normal file
View File

@@ -0,0 +1,162 @@
body {
font-family: system-ui, sans-serif;
margin-top: 0;
}
h1 {
margin-top: 0px;
}
header {
margin: 0 auto;
max-width: 80em;
box-shadow: 0 0 5px 5px #bbb;
}
main {
margin: 0 auto;
max-width: 80em;
padding-bottom: 150px;
min-height: 60dvh;
max-height: 90dvh;
}
header > p, header > h1, header > sub {
padding: 0 20px;
}
h1, h2, h3, h4 {
margin-top: 0px;
padding-top: 10px;
padding-bottom: 5px;
}
div.hr {
color: #6100ba;
background: #6100ba;
height: 3px;
}
nav {
margin-top: 10px;
background: #6100ba;
background-image: linear-gradient(to bottom, #6100ba, #3d0076);
display: flex;
align-items: center;
height: 30px;
}
nav > ul {
padding-left: 12px;
list-style: none;
align-items: center;
}
nav > ul > li > a {
color: whitesmoke;
font-weight: bold;
text-decoration: none;
padding-left: 10px;
font-size: large;
}
ul {
display: flex;
flex-wrap: nowrap;
align-content: center;
height: fit-content;
}
a:hover {
text-shadow: 0 0 2px white;
}
section {
padding: 0 20px;
display: none;
}
input {
margin-top: 5px;
}
.flex-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.success {
color: green;
}
.error {
color: red;
}
#details-success {
display: none;
}
#details-error {
display: none;
}
#player {
display: flex;
align-items: center;
flex-direction: column;
}
#player > img {
max-width: 512px;
max-height: 512px;
margin-bottom: 20px;
}
#player > p {
margin: 2px 0;
}
#controls {
display: flex;
margin-top: 10px;
}
#controls > input {
width: 48px;
margin-right: 16px;
}
@media (prefers-color-scheme: dark) {
body {
background: #111;
color: whitesmoke;
}
header {
box-shadow: 0 -5px 5px 5px BLACK;
background: #1a1a1a;
}
main {
box-shadow: 0 5px 5px 3px black;
background: #1a1a1a;
}
}
@media (prefers-color-scheme: light) {
body {
background: #e4e4e4;
}
header {
box-shadow: 0 -5px 5px 5px #bbb;
background: white;
}
main {
box-shadow: 0 5px 5px 5px #bbb;
background: white;
}
}

3
src/srv/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

122
src/srv/proxy.ts Normal file
View File

@@ -0,0 +1,122 @@
/* Proxy to bypass CORS (tauon does not pass these headers) */
import express from "express"
import cors from "cors"
import compression from "compression"
const app = express()
app.use(cors())
app.use(compression())
app.get("/version", (req, res) => {
let host = req.query.host
if (typeof host !== "string") {
return res.status(400).send("No host")
}
if (!host.includes(":")) {
host = host + ":7814"
}
fetch(`http://${host}/api1/version`)
.then(fetchRes => fetchRes.json())
.then(fetchRes => {
return res.status(200).send(fetchRes)
})
.catch((err) => {
return res.status(500).send(err)
})
})
app.get("/status", (req, res) => {
let host = req.query.host
if (typeof host !== "string") {
return res.status(400).send("No host")
}
if (!host.includes(":")) {
host = host + ":7814"
}
fetch(`http://${host}/api1/status`)
.then(fetchRes => fetchRes.json())
.then(fetchRes => {
return res.status(200).send(fetchRes)
})
.catch((err) => {
return res.status(500).send(err)
})
})
app.get("/pic", (req, res) => {
let host = req.query.host
const size = req.query.size
const id = req.query.id
if (typeof host !== "string" || !size || !id)
return res.status(400).send("Bad parameters")
if (!host.includes(":")) {
host = host + ":7814"
}
fetch(`http://${host}/api1/pic/${size}/${id}`)
.then(raw => raw.arrayBuffer())
.then(arrBuff => Buffer.from(arrBuff))
.then(buff => {
return res.send(buff)
})
})
app.get("/play", (req, res) => {
let host = req.query.host
if (typeof host !== "string")
return res.status(400).send("Bad host")
if (!host.includes(":")) {
host = host + ":7814"
}
fetch(`http://${host}/api1/play`)
return res.status(200).end()
})
app.get("/pause", (req, res) => {
let host = req.query.host
if (typeof host !== "string")
return res.status(400).send("Bad host")
if (!host.includes(":")) {
host = host + ":7814"
}
fetch(`http://${host}/api1/pause`)
return res.status(200).end()
})
app.get("/next", (req, res) => {
let host = req.query.host
if (typeof host !== "string")
return res.status(400).send("Bad host")
if (!host.includes(":")) {
host = host + ":7814"
}
fetch(`http://${host}/api1/next`)
return res.status(200).end()
})
app.get("/back", (req, res) => {
let host = req.query.host
if (typeof host !== "string")
return res.status(400).send("Bad host")
if (!host.includes(":")) {
host = host + ":7814"
}
fetch(`http://${host}/api1/back`)
return res.status(200).end()
})
app.listen(7815, () => console.log("MuonRC Proxy listening on 7815"))

11
src/srv/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true,
"composite": true,
"esModuleInterop": true
}
}