diff --git a/index.html b/index.html index bec4b7f..d17bf2c 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,8 @@ - - digital-turntable + Digital Turntable
diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.css b/src/App.css index b9d355d..6cf7a68 100644 --- a/src/App.css +++ b/src/App.css @@ -1,42 +1,16 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +.welcome { + display: grid; + height: 100%; + place-items: center; + color: var(--muted); + font-size: 20px; + letter-spacing: 0.5px; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; +.app { + height: 100%; + display: grid; + grid-template-rows: 1fr 72px; + grid-template-columns: 500px 1fr; + gap: 1em; } diff --git a/src/App.tsx b/src/App.tsx index 3d7ded3..902de0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,181 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { useState, useEffect } from 'react' +import { MusicBrainzApi, type IRelease, type ITrack } from 'musicbrainz-api'; +import Album from "./components/Album/Album"; +import AlbumArt from "./components/AlbumArt/AlbumArt"; +import NowPlaying from "./components/NowPlaying/NowPlaying"; +import packageJson from '../package.json'; import './App.css' +import type { Album as AlbumType, PlayableTrack, Track } from './album'; + +const mbApi = new MusicBrainzApi({ + appName: packageJson.name, + appVersion: packageJson.version, + appContactInfo: packageJson.author.email, +}); + +function requireEnv(name: string): string { + const value = import.meta.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +async function getCoverArtSrcURL(releaseId: string, releaseGroupId: string): Promise { + for (const url of [`/release/${releaseId}`, `/release-group/${releaseGroupId}`]) { + const res = await fetch("https://coverartarchive.org" + url); + if (!res.ok) { + continue; + } + const data = await res.json(); + const front = (data.images || []).find(img => img.front) || data.images?.[0]; + if (front) { + return front.thumbnails?.["1200"]|| front.image; + } + } + return null; +} + +const USER = requireEnv("VITE_NAVIDROME_USER"); +const PASSWORD = requireEnv("VITE_NAVIDROME_PASSWORD"); +const SERVER = requireEnv("VITE_NAVIDROME_URL").replace(/\/+$/, ""); // without trailing slash + +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(); +} function App() { - const [count, setCount] = useState(0) + const [album, setAlbum] = useState(null); + const [buffer, setBuffer] = useState([]); + const [nowPlaying, play] = useState(null); + const [searching, setSearching] = useState(false); + const [paused, setPaused] = useState(false); - return ( - <> -
- - Vite logo - - - React logo - + // 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 };