Compare commits

..

No commits in common. "6bddf284feceda5ec0ae2599ef41e2a98071d20d" and "2146def6092c1dede2c02a3b08ccf7b4a726118e" have entirely different histories.

9 changed files with 26 additions and 230 deletions

View file

@ -1,3 +0,0 @@
VITE_NAVIDROME_USER=chandler
VITE_NAVIDROME_PASSWORD=password
VITE_NAVIDROME_URL=https://music.chandlerswift.com

3
.gitignore vendored
View file

@ -22,6 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env
server/server

View file

@ -1,26 +0,0 @@
.PHONY: deploy clean start default frontend
server/server: frontend
rm -rf server/dist
mv dist server/
CGO_ENABLED=0 go -C server build .
frontend:
npm run build
deploy: server/server
scp server/server kiosk:
clean:
rm -rf dist server/dist server/server
start: deploy
# TODO: This doesn't kill weston nicely; I should handle that? Or background or something
ssh kiosk "pgrep -f './server' || ./server"
#ssh kiosk "pkill -f 'weston --shell=kiosk-shell.so' || true"
ssh kiosk weston --shell=kiosk-shell.so -- firefox --kiosk localhost:8000 --remote-debugging-port=9222
debug: deploy
ssh kiosk "pgrep -f './server' || ./server"
#ssh kiosk "pkill -f 'weston --shell=kiosk-shell.so' || true"
ssh -L 6000:localhost:6000 kiosk weston --shell=kiosk-shell.so -- firefox --kiosk localhost:8000 --start-debugger-server 6000

View file

@ -1,3 +0,0 @@
module git.chandlerswift.com/chandlerswift/digital-turntable/server
go 1.25.5

View file

@ -1,37 +0,0 @@
package main
import (
"embed"
"fmt"
"log"
"net/http"
"os"
)
//go:embed dist
var webUI embed.FS
func setReader(state string) error {
// echo 'on' > '/sys/bus/usb/devices/1-1/power/control' # or 'auto'
switch state {
case "auto", "on":
return os.WriteFile("/sys/bus/usb/devices/1-1/power/control", []byte(state), 0)
default:
return fmt.Errorf("unknown state %q", state)
}
}
func main() {
setReader("on")
http.Handle("/", http.FileServerFS(webUI))
http.HandleFunc("POST /api/reader/{state}", func(w http.ResponseWriter, r *http.Request) {
if err := setReader(r.PathValue("state")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte("ok"))
})
log.Fatal(http.ListenAndServe("127.0.0.1:8000", nil))
}

View file

@ -5,6 +5,5 @@ in
pkgs.mkShellNoCC {
packages = with pkgs; [
nodejs
gnumake
];
}

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { MusicBrainzApi, type IRelease, type IReleaseMatch, type ITrack } from 'musicbrainz-api';
import { MusicBrainzApi, type ITrack } from 'musicbrainz-api';
import Album from "./components/Album/Album";
import AlbumArt from "./components/AlbumArt/AlbumArt";
import NowPlaying from "./components/NowPlaying/NowPlaying";
@ -99,20 +99,20 @@ function App() {
// Handle keyboard/scanner input
useEffect(() => {
const handleKeyDown = (ev: KeyboardEvent) => {
ev.preventDefault();
if (ev.key === "F1") { // TODO: remove
lookupBarcode("075021370814"); // Breakfast in America; owned
} else if (ev.key === "F2") {
lookupBarcode("015775150522"); // Crime of the Century; not owned
} else if (ev.key === "F3") {
lookupBarcode("mbid:439e9e8e-931a-431f-8ed0-ef00303bbad0"); // Feinberg's Well-Tempered Clavier (long!)
} else if (ev.key === "F4") {
lookupBarcode("mbid:d2b11318-03cc-4bdc-8667-e39ef020f0f2"); // Connie Evingson, long title
if (ev.key === "s") { // TODO: remove
ev.preventDefault();
lookupBarcode("075021370814");
return;
} else if (ev.key === "d") {
ev.preventDefault();
lookupBarcode("015775150522");
return
} else if (ev.key === "Enter") {
ev.preventDefault();
const code = buffer.join("").trim();
setBuffer([]); // Reset the buffer
lookupBarcode(code);
} else if (/^[0-9A-Za-z:-]$/.test(ev.key)) {
} else if (/^[0-9A-Za-z:]$/.test(ev.key)) {
setBuffer((prevBuffer) => [...prevBuffer, ev.key]);
}
};
@ -131,26 +131,20 @@ function App() {
}
setSearching(true);
console.log("Scanned barcode:", barcode);
var mbRelease: IRelease | IReleaseMatch;
if (barcode.startsWith("mbid:")) {
// # We already have a musicbrainz ID; get its data
const releaseId = barcode.slice(5);
mbRelease = await mbApi.lookup('release', releaseId, ['artist-credits', 'release-groups']);
} else {
// # Lookup the barcode in MusicBrainz
// TODO: tweak this query to return a single album, with tracks, a release
// date, genre, artist info, and link to coverartdb.
const searchResponse = await mbApi.search('release', {
query: { barcode },
inc: ['artist-credits', 'release-groups', 'genres'], // TODO
});
if (searchResponse.count === 0) {
console.log("No album found for barcode:", barcode); // TODO: some kind of toast?
setSearching(false);
return;
}
mbRelease = searchResponse.releases[0];
// # Lookup the barcode in MusicBrainz
// TODO: tweak this query to return a single album, with tracks, a release
// date, genre, artist info, and link to coverartdb.
const searchResponse = await mbApi.search('release', {
query: { barcode },
inc: ['artist-credits', 'release-groups', 'genres'], // TODO
});
if (searchResponse.count === 0) {
console.log("No album found for barcode:", barcode); // TODO: some kind of toast?
setSearching(false);
return;
}
const mbRelease = searchResponse.releases[0];
// # Check Navidrome for this album
// https://opensubsonic.netlify.app/docs/endpoints/search3/

View file

@ -6,7 +6,6 @@
backdrop-filter: blur(8px);
box-shadow: var(--shadow);
height: 100%;
min-height: 0;
}
.meta {
@ -21,66 +20,13 @@
font-weight: 600;
letter-spacing: 0.3px;
margin: 0;
flex: 1 1 auto;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
min-width: 0;
overflow: hidden;
}
.title-viewport {
display: block;
overflow: hidden;
white-space: nowrap;
/* Defaults; overridden inline when marquee is active */
--marquee-gap: 96px;
--marquee-distance: 0px;
--marquee-duration: 0s;
--fade-edge: 18px;
}
.title-viewport.is-marquee {
/* Subtle fade at the edges (marquee mode only) */
mask-image: linear-gradient(
90deg,
transparent,
#000 var(--fade-edge),
#000 calc(100% - var(--fade-edge)),
transparent
);
mask-repeat: no-repeat;
mask-size: 100% 100%;
}
.title-track {
display: inline-flex;
align-items: baseline;
gap: var(--marquee-gap);
}
.title-text {
display: inline-block;
}
.title-viewport.is-marquee .title-track {
will-change: transform;
animation: title-marquee var(--marquee-duration) linear infinite;
}
@keyframes title-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-1 * var(--marquee-distance)));
}
flex-wrap: wrap;
}
.subtitle {
@ -120,5 +66,4 @@ button.dismiss {
padding: 6px 10px;
box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25);
margin-left: auto;
flex: 0 0 auto;
}

View file

@ -2,82 +2,12 @@ import Track from "./Track";
import "./Album.css";
import type { Album, Track as TrackType } from "../../album";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
export default function Album({ album, dismissAlbum, play, nowPlaying }: { album: Album; dismissAlbum: () => void, play: (i: number) => void, nowPlaying: TrackType }) {
const titleViewportRef = useRef<HTMLSpanElement | null>(null);
const titleTextRef = useRef<HTMLSpanElement | null>(null);
const [isMarquee, setIsMarquee] = useState(false);
const [marqueeDistancePx, setMarqueeDistancePx] = useState(0);
// Tunables (constant px/sec + visible gap between repeats)
const marqueeGapPx = 96;
const marqueeSpeedPxPerSec = 60;
useLayoutEffect(() => {
const viewportEl = titleViewportRef.current;
const textEl = titleTextRef.current;
if (!viewportEl || !textEl) return;
let raf = 0;
const measure = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const viewportWidth = viewportEl.clientWidth;
const textWidth = textEl.scrollWidth;
const shouldMarquee = textWidth > viewportWidth + 1;
setIsMarquee(shouldMarquee);
setMarqueeDistancePx(shouldMarquee ? textWidth + marqueeGapPx : 0);
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(viewportEl);
// Fonts can load after first paint and change measurements.
void (document as unknown as { fonts?: { ready: Promise<void> } }).fonts?.ready.then(measure);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, [album.title]);
const marqueeStyle = useMemo(() => {
if (!isMarquee) return undefined;
const durationSec = marqueeSpeedPxPerSec > 0 ? marqueeDistancePx / marqueeSpeedPxPerSec : 0;
return {
["--marquee-gap" as never]: `${marqueeGapPx}px`,
["--marquee-distance" as never]: `${marqueeDistancePx}px`,
["--marquee-duration" as never]: `${durationSec}s`,
};
}, [isMarquee, marqueeDistancePx]);
return (
<section className="panel">
<div className="meta">
<div className="title-row">
<h1 className="title" id="release-title">
<span
ref={titleViewportRef}
className={`title-viewport${isMarquee ? " is-marquee" : ""}`}
style={marqueeStyle}
title={album.title}
>
<span className="title-track">
<span ref={titleTextRef} className="title-text">{album.title}</span>
{isMarquee ? (
<span className="title-text title-text--clone">{album.title}</span>
) : null}
</span>
</span>
</h1>
<h1 className="title" id="release-title">{album.title}</h1>
<button type="button" className="dismiss" onClick={dismissAlbum}></button>
</div>
<p className="subtitle" id="release-artist">