Compare commits
8 commits
2146def609
...
6bddf284fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bddf284fe | |||
| 85c7c4fd9f | |||
| d188c25e6e | |||
| 0a5afe7210 | |||
| aea55b5aec | |||
| 26fac96b14 | |||
| f9435c3a21 | |||
| 1e58dd5c6f |
9 changed files with 230 additions and 26 deletions
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
VITE_NAVIDROME_USER=chandler
|
||||||
|
VITE_NAVIDROME_PASSWORD=password
|
||||||
|
VITE_NAVIDROME_URL=https://music.chandlerswift.com
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,3 +22,6 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
|
server/server
|
||||||
|
|
|
||||||
26
Makefile
Normal file
26
Makefile
Normal 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
3
server/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module git.chandlerswift.com/chandlerswift/digital-turntable/server
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
37
server/main.go
Normal file
37
server/main.go
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -5,5 +5,6 @@ in
|
||||||
pkgs.mkShellNoCC {
|
pkgs.mkShellNoCC {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
nodejs
|
nodejs
|
||||||
|
gnumake
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
src/App.tsx
54
src/App.tsx
|
|
@ -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();
|
if (ev.key === "F1") { // TODO: remove
|
||||||
lookupBarcode("075021370814");
|
lookupBarcode("075021370814"); // Breakfast in America; owned
|
||||||
return;
|
} else if (ev.key === "F2") {
|
||||||
} else if (ev.key === "d") {
|
lookupBarcode("015775150522"); // Crime of the Century; not owned
|
||||||
ev.preventDefault();
|
} else if (ev.key === "F3") {
|
||||||
lookupBarcode("015775150522");
|
lookupBarcode("mbid:439e9e8e-931a-431f-8ed0-ef00303bbad0"); // Feinberg's Well-Tempered Clavier (long!)
|
||||||
return
|
} 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,20 +131,26 @@ function App() {
|
||||||
}
|
}
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
console.log("Scanned barcode:", barcode);
|
console.log("Scanned barcode:", barcode);
|
||||||
|
var mbRelease: IRelease | IReleaseMatch;
|
||||||
// # Lookup the barcode in MusicBrainz
|
if (barcode.startsWith("mbid:")) {
|
||||||
// TODO: tweak this query to return a single album, with tracks, a release
|
// # We already have a musicbrainz ID; get its data
|
||||||
// date, genre, artist info, and link to coverartdb.
|
const releaseId = barcode.slice(5);
|
||||||
const searchResponse = await mbApi.search('release', {
|
mbRelease = await mbApi.lookup('release', releaseId, ['artist-credits', 'release-groups']);
|
||||||
query: { barcode },
|
} else {
|
||||||
inc: ['artist-credits', 'release-groups', 'genres'], // TODO
|
// # Lookup the barcode in MusicBrainz
|
||||||
});
|
// TODO: tweak this query to return a single album, with tracks, a release
|
||||||
if (searchResponse.count === 0) {
|
// date, genre, artist info, and link to coverartdb.
|
||||||
console.log("No album found for barcode:", barcode); // TODO: some kind of toast?
|
const searchResponse = await mbApi.search('release', {
|
||||||
setSearching(false);
|
query: { barcode },
|
||||||
return;
|
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
|
// # Check Navidrome for this album
|
||||||
// https://opensubsonic.netlify.app/docs/endpoints/search3/
|
// https://opensubsonic.netlify.app/docs/endpoints/search3/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue