diff --git a/src/About.tsx b/src/About.tsx new file mode 100644 index 0000000..3dc67c4 --- /dev/null +++ b/src/About.tsx @@ -0,0 +1,152 @@ +/** + * What the project is, and where it is going. The roadmap is data rather than + * markup so an item's status is a value with two allowed settings, not a class + * name that can be misspelled into invisibility. + */ + +type Status = 'done' | 'planned'; + +type Milestone = { + status: Status; + title: string; + detail: string; +}; + +/** Ordered as shown. Grouped by status at render time, so moving an item between + * the two lists means changing its `status` and nothing else. */ +const ROADMAP: Milestone[] = [ + { + status: 'done', + title: 'Three similarity sources, fused by rank', + detail: + 'Deezer, ListenBrainz and Last.fm each answer "who sounds like this?" differently. Their rankings are combined; their scores are not, being incomparable quantities.', + }, + { + status: 'done', + title: 'Second-hop expansion', + detail: + 'Artists similar to the artists similar to the seed, at a reduced weight. Worth 1.7 points of precision, so it stays on.', + }, + { + status: 'done', + title: 'Knobs that re-select without refetching', + detail: + 'Adventurousness, devotion, popularity penalty and per-artist cap all re-draw from a candidate pool already in memory. Only Generate goes back to the network.', + }, + { + status: 'done', + title: 'Filters for era, length, tempo, loudness and popularity', + detail: + 'Applied after the track details arrive, so a filter narrows a playlist rather than emptying it.', + }, + { + status: 'done', + title: 'Whole tracks, not thirty-second previews', + detail: 'Playback resolves each track to a YouTube video as the playlist advances.', + }, + { + status: 'done', + title: 'Playlist history', + detail: + 'Generating archives the playlist on screen instead of destroying it, up to twenty back. Any row can seed a new playlist from that track’s artist.', + }, + { + status: 'done', + title: 'Exports that belong to no platform', + detail: + 'CSV, plain text, M3U, XSPF and JSPF. The two playlist formats carry a YouTube location per track, and never drop a track for lacking one.', + }, + { + status: 'done', + title: 'Survives a reload', + detail: 'The candidate pool, the track details and the knob settings all come back.', + }, + { + status: 'planned', + title: 'A real playlist model', + detail: + 'Today there is one live playlist and a row of snapshots behind it — versions of a single thing rather than a collection. Several playlists at once, editing one after it is generated, and addressing one by a stable id all wait on settling what a playlist is.', + }, + { + status: 'planned', + title: 'Share URLs', + detail: + 'A link that opens a playlist. Carrying the settings is small but does not reproduce it, since the catalogues underneath keep changing; carrying the tracks reproduces it exactly but is far larger.', + }, + { + status: 'planned', + title: 'Sign in with atproto, playlists in your own PDS', + detail: + 'Playlists live in browser storage today, so they are stuck in the browser that made them. Kept as records in an account you control, they become portable.', + }, + { + status: 'planned', + title: 'A front-end that needs no server of its own', + detail: + 'Everything deciding what goes in a playlist already runs in the browser. What is left — one catalogue that sends no CORS headers, one API key, one lookup that cannot be done from a page — could be a handful of small functions instead of an origin.', + }, +]; + +const LABEL: Record = { done: 'Working now', planned: 'Planned' }; + +function Timeline({ items }: { items: Milestone[] }) { + return ( +
    + {items.map((m) => ( +
  1. + +
    {m.title}
    +
    {m.detail}
    +
  2. + ))} +
+ ); +} + +export default function About({ onBack }: { onBack: () => void }) { + return ( +
+ + +

About morelike

+ +

+ Name one artist. Get back about a hundred tracks that belong next to them — as plain + artist-and-title pairs, not as a link into somebody's catalogue. +

+

+ The playlist builders worth imitating were all wrappers around one large streaming service's + recommendation endpoint. That endpoint closed to new applications, and the tools built on it + narrowed to exporting into that one service. So this asks several sources instead of one, and + keeps the answer in a form any player can take. +

+

+ Three sources are asked who sounds like the seed, and their rankings are fused — + never their scores, which measure different things on different scales. The result is a pool + of candidate artists, drawn from and narrowed by knobs you can move without waiting for the + network again. Nothing about the playlist is tied to where it will be played. +

+ +
+ + {LABEL.done} + + + {LABEL.planned} + +
+ +

{LABEL.done}

+ m.status === 'done')} /> + +

{LABEL.planned}

+ m.status === 'planned')} /> + + +
+ ); +} diff --git a/src/App.tsx b/src/App.tsx index 9047559..d2ff946 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import { type SaveOutcome, } from './session'; import { usePlaylistPlayer } from './youtubePlayer'; +import About from './About'; /** * The roster must be considerably larger than the artist draw or the @@ -83,6 +84,9 @@ const TRACKS_PER_ARTIST = 25; export default function App() { const [status, setStatus] = useState(null); + /** The about page replaces the playlist rather than sitting beside it — the + * stage is one column, and playback carries on regardless. */ + const [showAbout, setShowAbout] = useState(false); const [history, setHistory] = useState([]); /** Which past playlist is on screen instead of the live one, if any. */ const [viewingId, setViewingId] = useState(null); @@ -613,6 +617,18 @@ export default function App() {
+ {/* Outside everything the about page replaces, and deliberately so: the + YouTube iframe must stay in the document at its real dimensions or + the browser is entitled to stop the audio. Reading the about page + does not interrupt playback. */} +
+
+
+ + {showAbout ? ( + setShowAbout(false)} /> + ) : ( + <>
{/* The generated playlist's seed, not the live input, so the heading names what is actually on screen rather than flickering per @@ -687,15 +703,6 @@ export default function App() {
- {/* The video is out of sight, not gone: playback still runs through - the YouTube iframe, which has to stay in the document at its real - dimensions. `display: none` and a zero-size frame are both grounds - for a browser to stop the audio, so it is moved off-screen - instead. The wrapper carries the positioning because YT.Player - replaces the element it is handed, taking any class with it. */} -
-
-
{artArtistId !== undefined && ( // Deezer redirects this to its image CDN and the Worker proxies @@ -920,6 +927,12 @@ export default function App() { )} + +

+ +

+ + )}
); diff --git a/src/styles.css b/src/styles.css index 94a47fb..ef3b8a6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -129,6 +129,62 @@ button:disabled { opacity: 0.35; cursor: default; } .reseed { border: none; padding: 0; color: var(--dim); font-size: 13px; line-height: 1; } .reseed:hover:not(:disabled) { color: var(--accent); } +/* About & roadmap. Narrower than the track list, which is a table and wants the + width; prose does not. */ +.about { max-width: 620px; margin: 0 0 80px; } +.about h1 { margin-bottom: 12px; } +.about h2 { font-size: 15px; margin: 32px 0 4px; } +.about p { margin: 0 0 12px; } +.about .sub { font-size: 13px; } +.back { margin: 0 0 24px; padding: 3px 8px; font-size: 12px; } +.about .back:last-child { margin: 32px 0 0; } + +.legend { display: flex; gap: 18px; flex-wrap: wrap; margin: 20px 0 0; color: var(--dim); font-size: 12px; } +.legend span { display: inline-flex; align-items: center; gap: 7px; } +/* The legend reuses .dot inline, so it has to undo the absolute positioning the + timeline relies on. */ +.legend .dot { position: static; } + +/* + * One line down the left with a status dot per row. The spine is inset top and + * bottom so it starts and ends at a dot rather than overhanging the list, and + * each dot's centre sits on it: the dot is 16px wide at left 0, the spine 2px at + * left 7px. + */ +.timeline { list-style: none; margin: 4px 0 0; padding: 0; position: relative; } +.timeline::before { + content: ''; + position: absolute; + top: 8px; + bottom: 8px; + left: 7px; + width: 2px; + background: var(--line); +} +.timeline li { position: relative; padding: 10px 0 10px 34px; } +.dot { + position: absolute; + left: 0; + top: 13px; + width: 16px; + height: 16px; + border-radius: 50%; + box-sizing: border-box; + border: 2px solid var(--line); + background: var(--bg); +} +/* Filled means shipped, hollow means not yet. Planned titles keep full contrast + — they are not lesser, only later. */ +.done .dot { background: var(--accent); border-color: var(--accent); } +.planned .dot { background: var(--bg); border-color: var(--dim); } +.item-title { font-weight: 600; } +.item-desc { color: var(--dim); font-size: 12px; margin-top: 2px; } + +/* Sits under the track list, quiet enough not to compete with it. */ +.about-link { margin: 40px 0 0; font-size: 12px; } +.about-link button { border: none; padding: 0; color: var(--dim); font-size: 12px; } +.about-link button:hover:not(:disabled) { color: var(--accent); } + .log { margin: 48px 0 80px; } .log h2 { font-size: 15px; margin: 28px 0 8px; } .log h3 { font-size: 14px; margin: 20px 0 4px; }