Compare commits
No commits in common. "6bddf284feceda5ec0ae2599ef41e2a98071d20d" and "2146def6092c1dede2c02a3b08ccf7b4a726118e" have entirely different histories.
6bddf284fe
...
2146def609
9 changed files with 26 additions and 230 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
VITE_NAVIDROME_USER=chandler
|
|
||||||
VITE_NAVIDROME_PASSWORD=password
|
|
||||||
VITE_NAVIDROME_URL=https://music.chandlerswift.com
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,6 +22,3 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.env
|
|
||||||
server/server
|
|
||||||
|
|
|
||||||
26
Makefile
26
Makefile
|
|
@ -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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module git.chandlerswift.com/chandlerswift/digital-turntable/server
|
|
||||||
|
|
||||||
go 1.25.5
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
|
|
@ -5,6 +5,5 @@ in
|
||||||
pkgs.mkShellNoCC {
|
pkgs.mkShellNoCC {
|
||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
nodejs
|
nodejs
|
||||||
gnumake
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
src/App.tsx
30
src/App.tsx
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 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,12 +131,7 @@ 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.
|
||||||
|
|
@ -149,8 +144,7 @@ function App() {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
return;
|
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,7 +6,6 @@
|
||||||
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 {
|
||||||
|
|
@ -21,66 +20,13 @@
|
||||||
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: nowrap;
|
flex-wrap: wrap;
|
||||||
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 {
|
||||||
|
|
@ -120,5 +66,4 @@ 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,82 +2,12 @@ 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">
|
<h1 className="title" id="release-title">{album.title}</h1>
|
||||||
<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