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
|
||||
*.sln
|
||||
*.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 {
|
||||
packages = with pkgs; [
|
||||
nodejs
|
||||
gnumake
|
||||
];
|
||||
}
|
||||
|
|
|
|||
30
src/App.tsx
30
src/App.tsx
|
|
@ -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) => {
|
||||
if (ev.key === "s") { // TODO: remove
|
||||
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
|
||||
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,12 +131,7 @@ 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.
|
||||
|
|
@ -149,8 +144,7 @@ function App() {
|
|||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
mbRelease = searchResponse.releases[0];
|
||||
}
|
||||
const mbRelease = searchResponse.releases[0];
|
||||
|
||||
// # Check Navidrome for this album
|
||||
// https://opensubsonic.netlify.app/docs/endpoints/search3/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue