diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2034805 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_NAVIDROME_USER=chandler +VITE_NAVIDROME_PASSWORD=password +VITE_NAVIDROME_URL=https://music.chandlerswift.com diff --git a/.gitignore b/.gitignore index a547bf3..7c2aa52 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +.env +server/server diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..50fcd46 --- /dev/null +++ b/Makefile @@ -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 diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..72330de --- /dev/null +++ b/server/go.mod @@ -0,0 +1,3 @@ +module git.chandlerswift.com/chandlerswift/digital-turntable/server + +go 1.25.5 diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..6ab1ecf --- /dev/null +++ b/server/main.go @@ -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)) +} diff --git a/shell.nix b/shell.nix index 084bd66..9e2dbba 100644 --- a/shell.nix +++ b/shell.nix @@ -5,5 +5,6 @@ in pkgs.mkShellNoCC { packages = with pkgs; [ nodejs + gnumake ]; } diff --git a/src/App.tsx b/src/App.tsx index ea05577..dc97a78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ 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 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) => { - if (ev.key === "s") { // TODO: remove - ev.preventDefault(); - lookupBarcode("075021370814"); - return; - } else if (ev.key === "d") { - ev.preventDefault(); - lookupBarcode("015775150522"); - return + 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 } 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,20 +131,26 @@ function App() { } setSearching(true); console.log("Scanned barcode:", barcode); - - // # 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; + 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]; } - const mbRelease = searchResponse.releases[0]; // # Check Navidrome for this album // https://opensubsonic.netlify.app/docs/endpoints/search3/ diff --git a/src/components/Album/Album.css b/src/components/Album/Album.css index b08da0b..c33c52f 100644 --- a/src/components/Album/Album.css +++ b/src/components/Album/Album.css @@ -6,6 +6,7 @@ backdrop-filter: blur(8px); box-shadow: var(--shadow); height: 100%; + min-height: 0; } .meta { @@ -20,13 +21,66 @@ 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: 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 { @@ -66,4 +120,5 @@ button.dismiss { padding: 6px 10px; box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25); margin-left: auto; + flex: 0 0 auto; } diff --git a/src/components/Album/Album.tsx b/src/components/Album/Album.tsx index 2b84aed..f23be64 100644 --- a/src/components/Album/Album.tsx +++ b/src/components/Album/Album.tsx @@ -2,12 +2,82 @@ 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(null); + const titleTextRef = useRef(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 } }).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 (
-

{album.title}

+

+ + + {album.title} + {isMarquee ? ( + {album.title} + ) : null} + + +