Compare commits

..

8 commits

Author SHA1 Message Date
6bddf284fe
Add go server 2026-02-12 00:09:06 -06:00
85c7c4fd9f
Add long-titled album shortcut to test side-scrolling 2026-02-11 22:49:32 -06:00
d188c25e6e
Slop: Add scrolling title
Prompt:
> Sometimes this album title is too long, and it overflows the box. I don't want it to do that. Instead, I want it to be cut off at max width, and then to have a marquee effect, translating left. For example:
> `Monty Python and the`
> becomes
> `ty Python and the Hol`
> becomes
> `thon and the Holy Grail`
> becomes
> `the Holy Grail     Monty Py`
> (note a many-space gap between Grail and Monty as the title restarts)
>
> How can I create this effect?
2026-02-11 22:48:21 -06:00
0a5afe7210
Handle track listing overflow 2026-02-11 22:12:30 -06:00
aea55b5aec
Add lookup-by-mbid functionality
MBID is a MusicBrainz ID: https://musicbrainz.org/doc/MusicBrainz_Identifier
2026-02-11 20:47:32 -06:00
26fac96b14
Refactor input handlings slightly 2026-02-11 20:46:14 -06:00
f9435c3a21
Add Makefile 2026-02-11 19:36:36 -06:00
1e58dd5c6f
Add .env file
This means I don't always have to specify the secrets on the command line.
2026-02-11 19:35:20 -06:00
9 changed files with 230 additions and 26 deletions

3
.env.example Normal file
View file

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

3
.gitignore vendored
View file

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

26
Makefile Normal file
View file

@ -0,0 +1,26 @@
.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

3
server/go.mod Normal file
View file

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

37
server/main.go Normal file
View file

@ -0,0 +1,37 @@
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,5 +5,6 @@ in
pkgs.mkShellNoCC { pkgs.mkShellNoCC {
packages = with pkgs; [ packages = with pkgs; [
nodejs nodejs
gnumake
]; ];
} }

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { MusicBrainzApi, type ITrack } from 'musicbrainz-api'; import { MusicBrainzApi, type IRelease, type IReleaseMatch, type ITrack } from 'musicbrainz-api';
import Album from "./components/Album/Album"; import Album from "./components/Album/Album";
import AlbumArt from "./components/AlbumArt/AlbumArt"; import AlbumArt from "./components/AlbumArt/AlbumArt";
import NowPlaying from "./components/NowPlaying/NowPlaying"; import NowPlaying from "./components/NowPlaying/NowPlaying";
@ -99,20 +99,20 @@ function App() {
// Handle keyboard/scanner input // Handle keyboard/scanner input
useEffect(() => { useEffect(() => {
const handleKeyDown = (ev: KeyboardEvent) => { const handleKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "s") { // TODO: remove
ev.preventDefault(); ev.preventDefault();
lookupBarcode("075021370814"); if (ev.key === "F1") { // TODO: remove
return; lookupBarcode("075021370814"); // Breakfast in America; owned
} else if (ev.key === "d") { } else if (ev.key === "F2") {
ev.preventDefault(); lookupBarcode("015775150522"); // Crime of the Century; not owned
lookupBarcode("015775150522"); } else if (ev.key === "F3") {
return 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
} else if (ev.key === "Enter") { } else if (ev.key === "Enter") {
ev.preventDefault();
const code = buffer.join("").trim(); const code = buffer.join("").trim();
setBuffer([]); // Reset the buffer setBuffer([]); // Reset the buffer
lookupBarcode(code); 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]); setBuffer((prevBuffer) => [...prevBuffer, ev.key]);
} }
}; };
@ -131,7 +131,12 @@ function App() {
} }
setSearching(true); setSearching(true);
console.log("Scanned barcode:", barcode); 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 // # Lookup the barcode in MusicBrainz
// TODO: tweak this query to return a single album, with tracks, a release // TODO: tweak this query to return a single album, with tracks, a release
// date, genre, artist info, and link to coverartdb. // date, genre, artist info, and link to coverartdb.
@ -144,7 +149,8 @@ function App() {
setSearching(false); setSearching(false);
return; return;
} }
const mbRelease = searchResponse.releases[0]; mbRelease = searchResponse.releases[0];
}
// # Check Navidrome for this album // # Check Navidrome for this album
// https://opensubsonic.netlify.app/docs/endpoints/search3/ // https://opensubsonic.netlify.app/docs/endpoints/search3/

View file

@ -6,6 +6,7 @@
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
box-shadow: var(--shadow); box-shadow: var(--shadow);
height: 100%; height: 100%;
min-height: 0;
} }
.meta { .meta {
@ -20,13 +21,66 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.3px; letter-spacing: 0.3px;
margin: 0; margin: 0;
flex: 1 1 auto;
min-width: 0;
} }
.title-row { .title-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex-wrap: wrap; 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)));
}
} }
.subtitle { .subtitle {
@ -66,4 +120,5 @@ button.dismiss {
padding: 6px 10px; padding: 6px 10px;
box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25); box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25);
margin-left: auto; margin-left: auto;
flex: 0 0 auto;
} }

View file

@ -2,12 +2,82 @@ import Track from "./Track";
import "./Album.css"; import "./Album.css";
import type { Album, Track as TrackType } from "../../album"; 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 }) { 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 ( return (
<section className="panel"> <section className="panel">
<div className="meta"> <div className="meta">
<div className="title-row"> <div className="title-row">
<h1 className="title" id="release-title">{album.title}</h1> <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>
<button type="button" className="dismiss" onClick={dismissAlbum}></button> <button type="button" className="dismiss" onClick={dismissAlbum}></button>
</div> </div>
<p className="subtitle" id="release-artist"> <p className="subtitle" id="release-artist">