-
-
-
-
-
-
+ // Handle 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
+ } 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)) {
+ setBuffer((prevBuffer) => [...prevBuffer, ev.key]);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [buffer]);
+
+ async function lookupBarcode(barcode: string) {
+ if (!barcode) {
+ console.log("empty barcode; ignoring");
+ return;
+ }
+ 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;
+ }
+ const mbRelease = searchResponse.releases[0];
+
+ // # Check Navidrome for this album
+ // https://opensubsonic.netlify.app/docs/endpoints/search3/
+ const query = mbRelease.title + ' ' + mbRelease['artist-credit']?.[0].name || '';
+ const ndRes = await fetch(navidromeURL('/rest/search3.view', { query, type: 'album', albumCount: "1", songCount: "0" })).then(res => res.json());
+ const ndFoundAlbum = ndRes["subsonic-response"].searchResult3.album?.[0];
+ if (ndFoundAlbum) {
+ // # Navidrome has the album! Now get the extended album deets:
+ const ndRes = await fetch(navidromeURL('/rest/getAlbum.view', { id: ndFoundAlbum.id })).then(res => res.json());
+ const ndAlbum = ndRes["subsonic-response"].album;
+ console.log("Found album in Navidrome:", ndAlbum);
+ let tracks: PlayableTrack[] = ndAlbum.song?.map((t: any) => ({
+ title: t.title,
+ duration: t.duration,
+ id: t.id,
+ source: navidromeURL('/rest/stream.view', { id: t.id }),
+ })) ?? [];
+ // # Populate the album state…
+ setAlbum({
+ title: ndAlbum.name,
+ artistName: ndAlbum.artist ?? '',
+ releaseDate: ndAlbum.originalReleaseDate??'',
+ tracks,
+ coverArtLink: navidromeURL('/rest/getCoverArt.view', { id: ndAlbum.coverArt }),
+ })
+ // # …and play!
+ // play(album.tracks[0]);
+ // TODO
+ setSearching(false);
+ } else { // Not found in Navidrome; populate with MusicBrainz data instead
+ 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
+ id: t.id,
+ title: t.title,
+ duration: t.length / 1000 || 0,
+ }));
+ console.log(fullMbRelease);
+ setAlbum({
+ title: fullMbRelease.title,
+ artistName: fullMbRelease['artist-credit']?.[0].name || '', // TODO
+ releaseDate: fullMbRelease.date || '',
+ tracks,
+ coverArtLink: await getCoverArtSrcURL(fullMbRelease.id, fullMbRelease['release-group'].id) || '',
+ })
+ console.log(album);
+ setSearching(false);
+ }
+ }
+
+ if (album) {
+ // If the Album is a PlayableAlbum
+ const player = album.tracks[0]?.source ? (
+
+ ) : (
+
This album cannot be played, as Chandler doesn't own a copy.
+ );
+ return (
+
+
setAlbum(null)} play={play} nowPlaying={nowPlaying} />
+
+ {player}
-
Vite + React
-
-
-
- Edit src/App.tsx and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
- >
- )
+ )
+ } else if (searching) {
+ return (
Searching…
);
+ } else {
+ return (
Scan an album to begin.
);
+ }
}
export default App
diff --git a/src/album.tsx b/src/album.tsx
new file mode 100644
index 0000000..27754de
--- /dev/null
+++ b/src/album.tsx
@@ -0,0 +1,14 @@
+export interface Album
{
+ title: string;
+ artistName: string;
+ releaseDate: any; // TODO: Date;
+ tracks: TTrack[];
+ coverArtLink: string;
+}
+
+export interface Track {
+ title: string;
+ duration: number; // seconds
+ id: string;
+ source?: string;
+}
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/components/Album/Album.css b/src/components/Album/Album.css
new file mode 100644
index 0000000..b08da0b
--- /dev/null
+++ b/src/components/Album/Album.css
@@ -0,0 +1,69 @@
+.panel {
+ background: var(--panel);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 16px;
+ padding: 20px;
+ backdrop-filter: blur(8px);
+ box-shadow: var(--shadow);
+ height: 100%;
+}
+
+.meta {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ height: 100%;
+}
+
+.title {
+ font-size: 32px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ margin: 0;
+}
+
+.title-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.subtitle {
+ color: var(--muted);
+ font-size: 18px;
+ margin: 0;
+}
+
+.queue {
+ display: grid;
+ gap: 6px;
+ margin-top: 4px;
+ overflow-y: auto;
+ padding-right: 4px;
+ flex: 1;
+ min-height: 0;
+ align-content: start;
+}
+
+button {
+ background: linear-gradient(135deg, #22d3ee, #2dd4bf);
+ color: #04202a;
+ border: none;
+ border-radius: 12px;
+ padding: 10px 14px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ cursor: pointer;
+ box-shadow: 0 10px 30px rgba(45, 212, 191, 0.35);
+ transition: box-shadow 120ms ease, border-color 120ms ease, color 120ms ease;
+}
+
+button.dismiss {
+ background: transparent;
+ color: #ff6b6b;
+ border: 1px solid rgba(255, 70, 70, 0.7);
+ padding: 6px 10px;
+ box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25);
+ margin-left: auto;
+}
diff --git a/src/components/Album/Album.tsx b/src/components/Album/Album.tsx
new file mode 100644
index 0000000..4c9c1eb
--- /dev/null
+++ b/src/components/Album/Album.tsx
@@ -0,0 +1,24 @@
+import Track from "./Track";
+import "./Album.css";
+import type { Album, Track as TrackType } from "../../album";
+
+export default function Album({ album, dismissAlbum, play, nowPlaying }: { album: Album; dismissAlbum: () => void, play: (track: TrackType) => void, nowPlaying: any }) {
+ return (
+
+
+
+
{album.title}
+
+
+
+ {album.artistName} {album.releaseDate.year ? ' • ' + album.releaseDate.year : ''} {/* TODO: .toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) */}
+
+
+ {album.tracks.map(t => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/Album/Track.module.css b/src/components/Album/Track.module.css
new file mode 100644
index 0000000..2e284b4
--- /dev/null
+++ b/src/components/Album/Track.module.css
@@ -0,0 +1,33 @@
+.track {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.03);
+ font-size: 14px;
+ color: #e7f2ff;
+ min-height: 48px;
+ max-height: 48px;
+ overflow: hidden;
+}
+
+.title {
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0;
+}
+
+.runtime {
+ color: var(--muted);
+ font-size: 13px;
+ margin: 0;
+}
+
+.active {
+ border-color: rgba(45, 212, 191, 0.7);
+ box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
+ background: rgba(45, 212, 191, 0.08);
+}
diff --git a/src/components/Album/Track.tsx b/src/components/Album/Track.tsx
new file mode 100644
index 0000000..a838f61
--- /dev/null
+++ b/src/components/Album/Track.tsx
@@ -0,0 +1,14 @@
+import type { Track } from "../../album";
+import css from "./Track.module.css";
+
+export default function Track({track, play, nowPlaying}:{track:any, play: (track:Track) => void, nowPlaying:any}) {
+ const handler = track.source ? () => play(track) : undefined;
+ return (
+ <>
+
+
{track.title}
+
{Math.floor(track.duration / 60)}:{(Math.round(track.duration % 60)).toString().padStart(2, '0')}
+
+ >
+ );
+}
diff --git a/src/components/AlbumArt/AlbumArt.css b/src/components/AlbumArt/AlbumArt.css
new file mode 100644
index 0000000..0f5b517
--- /dev/null
+++ b/src/components/AlbumArt/AlbumArt.css
@@ -0,0 +1,20 @@
+.cover {
+ overflow: hidden;
+ width: min(100%, calc(100vh - 135px));
+ height: 100%;
+ aspect-ratio: 1 / 1;
+ display: block;
+ border-radius: 14px;
+ background: rgba(0, 0, 0, 0.4);
+ padding: 0;
+ margin: 0 auto;
+}
+
+.cover img {
+ width: 100%;
+ height: 100%;
+ max-height: 100%;
+ max-width: 100%;
+ object-fit: contain;
+ display: block;
+}
diff --git a/src/components/AlbumArt/AlbumArt.tsx b/src/components/AlbumArt/AlbumArt.tsx
new file mode 100644
index 0000000..4525945
--- /dev/null
+++ b/src/components/AlbumArt/AlbumArt.tsx
@@ -0,0 +1,10 @@
+import type { IRelease } from "musicbrainz-api";
+import "./AlbumArt.css";
+
+export default function AlbumArt({ url }: { url: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/NowPlaying/NowPlaying.css b/src/components/NowPlaying/NowPlaying.css
new file mode 100644
index 0000000..f2416ff
--- /dev/null
+++ b/src/components/NowPlaying/NowPlaying.css
@@ -0,0 +1,65 @@
+
+.progress {
+ height: 72px;
+ grid-column: 1 / -1;
+ width: 100%;
+ padding: 10px 12px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 12px;
+ align-items: stretch;
+}
+
+.progress-body {
+ display: grid;
+ gap: 8px;
+ align-content: center;
+}
+
+.progress-track {
+ position: relative;
+ height: 10px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 0%;
+ background: linear-gradient(135deg, #22d3ee, #2dd4bf);
+ border-radius: 999px;
+ transition: width 120ms ease;
+}
+
+.progress-label {
+ font-size: 13px;
+ color: var(--muted);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ min-height: 32px;
+}
+
+button#play-pause {
+ width: 48px;
+ height: 48px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.06);
+ color: #d9e5f5;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ box-shadow: none;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ padding-top: 6px;
+ font-size: 25px;
+ font-family: "Noto Sans Symbols 2", system-ui, sans-serif; /* without this, the pause symbol looks weird? Not sure why. */
+}
diff --git a/src/components/NowPlaying/NowPlaying.tsx b/src/components/NowPlaying/NowPlaying.tsx
new file mode 100644
index 0000000..be88126
--- /dev/null
+++ b/src/components/NowPlaying/NowPlaying.tsx
@@ -0,0 +1,19 @@
+import type { Track } from '../../album';
+import './NowPlaying.css';
+
+export default function NowPlaying({ nowPlaying, paused, setPaused }: { nowPlaying: Track|null; paused: boolean; setPaused: (paused: boolean) => void }) {
+ return (
+
+
+
+
+ {nowPlaying?.title??"—"}
+ 0:00 / 0:00
+
+
+
+
+ );
+}
diff --git a/src/index.css b/src/index.css
index 08a3ac9..3774f51 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,68 +1,30 @@
:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
-
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ /* Generated by LLM */
+ --bg: #0b1f2b;
+ --accent: #2dd4bf;
+ --muted: #9fb3c8;
+ --panel: rgba(255, 255, 255, 0.06);
+ --shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
+* {
+ box-sizing: border-box;
}
body {
+ /* Originally generated by LLM */
margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
+ height: 100vh;
+ font-family: Arial, sans-serif;
+ background: radial-gradient(circle at 20% 20%, rgba(45, 212, 191, 0.25), transparent 25%),
+ radial-gradient(circle at 80% 30%, rgba(255, 255, 255, 0.08), transparent 22%),
+ radial-gradient(circle at 35% 70%, rgba(200, 232, 223, 0.13), transparent 49%),
+ linear-gradient(135deg, #081620, #0b1f2b 40%, #0f2f3c);
+ color: #f7fbff;
+ user-select: none;
+ padding: 24px;
}
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
-}
-
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
-}
-button:hover {
- border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
+#root {
+ height: 100%;
}
diff --git a/src/services/Musicbrainz.ts b/src/services/Musicbrainz.ts
new file mode 100644
index 0000000..f79ffcf
--- /dev/null
+++ b/src/services/Musicbrainz.ts
@@ -0,0 +1 @@
+export { FindReleaseByBarcode };
diff --git a/src/services/Navidrome.ts b/src/services/Navidrome.ts
new file mode 100644
index 0000000..cb9545f
--- /dev/null
+++ b/src/services/Navidrome.ts
@@ -0,0 +1,33 @@
+function navidromeURL(path: string, params: Record): string {
+ path = path.replace(/^\/+/, ""); // without leading slash
+ return `${SERVER}/${path}?` + new URLSearchParams({
+ ...params,
+ u: USER,
+ p: PASSWORD,
+ c: "digital-turntable",
+ v: "1.16.1",
+ f: "json"
+ }).toString();
+}
+
+async function GetAlbumByID(albumId: string): Promise {
+ // https://opensubsonic.netlify.app/docs/endpoints/getalbum/
+ const res = await fetch(url('/rest/getAlbum.view', {id: albumId}));
+ if (!res.ok) {
+ throw new Error(`Navidrome getAlbum failed: ${res.status} ${res.statusText}`);
+ }
+ const data = await res.json();
+ return data["subsonic-response"].album;
+}
+
+async function SearchAlbum(query: string): Promise {
+
+ const data = await res.json();
+ const albums = data["subsonic-response"].searchResult3?.album;
+ if (!albums || albums.length === 0) {
+ return null;
+ }
+ return albums[0];
+}
+
+export { GetAlbumByID, SearchAlbum };