Compare commits

...

5 commits

Author SHA1 Message Date
52ad6cc804
Slop: Add systemd user service for server 2026-02-18 20:39:54 -06:00
9bb7f8d147
Turn off barcode reader after 15m timeout
A future enhancement would be to make sure it only times out 15m after
an album is done playing (possibly by ignoring signals while an album is
playing, and then at the end of the album set the timer again?) but that
wasn't something I've implemented yet.
2026-02-18 20:38:34 -06:00
31d0bddc38
Reset on album end 2026-02-18 20:36:39 -06:00
0614fa1460
Extract out WELCOME_TEXT 2026-02-18 20:35:22 -06:00
ceebc2edb4
Generalize welcome message
This will be helpful in preparation for the next step: Timeout on idle.
2026-02-12 16:40:03 -06:00
3 changed files with 49 additions and 16 deletions

View file

@ -1,4 +1,4 @@
.PHONY: deploy clean start default frontend .PHONY: deploy clean start debug frontend install-service
server/server: frontend server/server: frontend
rm -rf server/dist rm -rf server/dist
@ -8,19 +8,23 @@ server/server: frontend
frontend: frontend:
npm run build npm run build
deploy: server/server deploy: server/server install-service
scp server/server kiosk: ssh kiosk "systemctl --user stop digital-turntable.service" || true
scp server/server kiosk:~/server
ssh kiosk "chmod +x ~/server && systemctl --user start digital-turntable.service"
install-service:
ssh kiosk "mkdir -p ~/.config/systemd/user"
scp server/digital-turntable.service kiosk:~/.config/systemd/user/digital-turntable.service
ssh kiosk "systemctl --user daemon-reload && systemctl --user enable digital-turntable.service"
clean: clean:
rm -rf dist server/dist server/server rm -rf dist server/dist server/server
start: deploy 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 "pkill -f 'weston --shell=kiosk-shell.so'" || true
ssh kiosk weston --shell=kiosk-shell.so -- firefox --kiosk localhost:8000 --remote-debugging-port=9222 ssh kiosk weston --shell=kiosk-shell.so -- firefox --kiosk localhost:8000 --remote-debugging-port=9222
debug: deploy debug: deploy
ssh kiosk "pgrep -f './server' || ./server &"
ssh kiosk "pkill -f 'weston --shell=kiosk-shell.so'" || true 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 ssh -L 6000:localhost:6000 kiosk weston --shell=kiosk-shell.so -- firefox --kiosk localhost:8000 --start-debugger-server 6000

View file

@ -0,0 +1,13 @@
[Unit]
Description=Digital Turntable kiosk server
After=network.target
[Service]
Type=simple
WorkingDirectory=%h
ExecStart=%h/server
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target

View file

@ -52,20 +52,40 @@ function navidromeURL(path: string, params: Record<string, string>): string {
}).toString(); }).toString();
} }
const WELCOME_TEXT = "Scan an album to begin.";
function App() { function App() {
const [album, setAlbum] = useState<AlbumType | null>(null); const [album, setAlbum] = useState<AlbumType | null>(null);
const [buffer, setBuffer] = useState<string[]>([]); const [buffer, setBuffer] = useState<string[]>([]);
const [nowPlaying, play] = useState<number>(0); const [nowPlaying, play] = useState<number>(0);
const [searching, setSearching] = useState(false); const [displayText, setDisplayText] = useState(WELCOME_TEXT);
const [paused, setPaused] = useState(true); const [paused, setPaused] = useState(true);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const [lastClicked, setLastClicked] = useState(new Date);
const click = () => setLastClicked(new Date);
useEffect(() => {
const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
window.setTimeout(() => {
setDisplayText("Asleep. Tap screen to scan.");
fetch('/api/reader/auto', { method: 'POST' });
}, lastClicked.getTime() + IDLE_TIMEOUT_MS - new Date().getTime());
return () => {
setDisplayText(WELCOME_TEXT);
fetch('/api/reader/on', { method: 'POST' });
}
}, [lastClicked]);
const handleEnded = () => { const handleEnded = () => {
if ((album?.tracks?.length ?? 0) > nowPlaying + 1) { // if there's a next track if ((album?.tracks?.length ?? 0) > nowPlaying + 1) { // if there's a next track
play(nowPlaying + 1); play(nowPlaying + 1);
setPaused(false); setPaused(false);
} else {
setAlbum(null);
setDisplayText(WELCOME_TEXT); // Either I could always be diligent about resetting it elsewhere…or I can just do it here!
} }
}; };
@ -129,7 +149,7 @@ function App() {
console.log("empty barcode; ignoring"); console.log("empty barcode; ignoring");
return; return;
} }
setSearching(true); setDisplayText("Searching…");
console.log("Scanned barcode:", barcode); console.log("Scanned barcode:", barcode);
var mbRelease: IRelease | IReleaseMatch; var mbRelease: IRelease | IReleaseMatch;
if (barcode.startsWith("mbid:")) { if (barcode.startsWith("mbid:")) {
@ -145,8 +165,8 @@ function App() {
inc: ['artist-credits', 'release-groups', 'genres'], // TODO inc: ['artist-credits', 'release-groups', 'genres'], // TODO
}); });
if (searchResponse.count === 0) { if (searchResponse.count === 0) {
console.log("No album found for barcode:", barcode); // TODO: some kind of toast? setDisplayText("No album found for barcode: " + barcode);
setSearching(false); setTimeout(() => setDisplayText(WELCOME_TEXT), 3000);
return; return;
} }
mbRelease = searchResponse.releases[0]; mbRelease = searchResponse.releases[0];
@ -179,7 +199,6 @@ function App() {
// # …and play! // # …and play!
play(0); play(0);
setPaused(false); setPaused(false);
setSearching(false);
} else { // Not found in Navidrome; populate with MusicBrainz data instead } else { // Not found in Navidrome; populate with MusicBrainz data instead
const fullMbRelease = await mbApi.lookup('release', mbRelease.id, ['recordings', 'artist-credits', 'release-groups']); const fullMbRelease = await mbApi.lookup('release', mbRelease.id, ['recordings', 'artist-credits', 'release-groups']);
const tracks: Track[] = fullMbRelease.media[0].tracks.map((t: ITrack) => ({ // TODO: handle multi-disc albums const tracks: Track[] = fullMbRelease.media[0].tracks.map((t: ITrack) => ({ // TODO: handle multi-disc albums
@ -196,7 +215,6 @@ function App() {
coverArtLink: await getCoverArtSrcURL(fullMbRelease.id, fullMbRelease['release-group']?.id || '') || '', coverArtLink: await getCoverArtSrcURL(fullMbRelease.id, fullMbRelease['release-group']?.id || '') || '',
}) })
console.log(album); console.log(album);
setSearching(false);
} }
} }
@ -214,7 +232,7 @@ function App() {
<p className='welcome' style={{ gridColumn: 'span 2' }}>This album cannot be played, as Chandler doesn't own a copy.</p> <p className='welcome' style={{ gridColumn: 'span 2' }}>This album cannot be played, as Chandler doesn't own a copy.</p>
); );
return ( return (
<div className="app"> <div className="app" onPointerDown={click}>
<audio <audio
ref={audioRef} ref={audioRef}
src={album.tracks[nowPlaying].source} src={album.tracks[nowPlaying].source}
@ -231,10 +249,8 @@ function App() {
{player} {player}
</div> </div>
) )
} else if (searching) {
return (<div className="welcome">Searching</div>);
} else { } else {
return (<div className="welcome">Scan an album to begin.</div>); return (<div className="welcome" onPointerDown={click}>{displayText}</div>);
} }
} }