diff --git a/README.md b/README.md index 8f82a7f..c74bac1 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ sonic doctor ```bash sonic # open the picker sonic "deep house" # search and play +sonic "james bond intro music, working" # describe a vibe sonic --soundcloud "liquid dnb" # search SoundCloud sonic --mixcloud "techno mix" # search Mixcloud sonic --spotify "daft punk" # search Spotify @@ -71,7 +72,7 @@ sonic --help # full help | --- | --- | --- | | `Enter` | Play selected | — | | `Space` | — | Pause / resume | -| `/` | Search | Search (stops playback) | +| `/` | Describe a vibe | New vibe (stops playback) | | `j` `k` / arrows | Move through lists | — | | `1`–`0` | Quick pick a vibe | — | | `Tab` / `Left` `Right` | Switch tabs | — | @@ -90,6 +91,10 @@ sonic --help # full help macOS media keys work via Control Center. +## Describe a vibe + +Press `/` and type what you want in plain words: a mood, a scene, an artist, or a song. Three or more words count as a vibe. Sonic searches your words and a long-mix version at once, then puts sets you can work to (20+ minutes, or live) first. On Spotify, a vibe finds playlists. + ## Themes Six built-in themes with dark and light variants: **Neon Night**, **Sunset Cassette**, **Ice Glass**, **Ember Void**, **Midnight Tokyo**, **Terminal Green**. Press `t` to cycle, `d` to toggle mode. diff --git a/bin/sonic.js b/bin/sonic.js index c6850b0..ae108bb 100755 --- a/bin/sonic.js +++ b/bin/sonic.js @@ -430,7 +430,7 @@ async function doSearch(query, { preset = false, label = null } = {}) { } setResultsView(outcome.value, { - title: 'search results', + title: preset ? 'search results' : 'your vibe', subtitle: `${source} · ${label || query}`, kind: 'search', emptyMessage: `no ${source} results for “${label || query}”. press m to try another source.` diff --git a/lib/search.js b/lib/search.js index 1ab3ecf..bd3bf4c 100644 --- a/lib/search.js +++ b/lib/search.js @@ -178,16 +178,47 @@ function toTrack(data, source, { preferWebpage = true } = {}) { // preset: long-session picks. On Spotify those map to playlists, not tracks. export async function search(query, { source = 'youtube', limit = 5, preset = false } = {}) { if (source === 'spotify') { - return preset - ? searchSpotify(shortPresetQuery(query), { limit, kind: 'playlist' }) + return preset || isDescription(query) + ? searchSpotify(preset ? shortPresetQuery(query) : query, { limit, kind: 'playlist' }) : searchSpotify(query, { limit, kind: 'track' }); } if (source === 'mixcloud') { return searchMixcloud(preset ? shortPresetQuery(query) : query, limit); } + if (!preset && isDescription(query)) { + return searchVibe(query, { source, limit }); + } return searchYtDlp(query, { source, limit }); } +// "james bond intro music, working vibe" describes a session, not a song. +function isDescription(query) { + return query.trim().split(/\s+/).length >= 3; +} + +const SESSION_SECONDS = 20 * 60; + +// Search the words as typed and as a long mix at once, then put sets you can +// work to (20+ minutes, or live) ahead of single songs. +async function searchVibe(query, { source, limit }) { + const mixQuery = /\b(mix|playlist|hours?|set)\b/i.test(query) ? `${query} long` : `${query} mix`; + const [asTyped, asMix] = await Promise.allSettled([ + searchYtDlp(query, { source, limit }), + searchYtDlp(mixQuery, { source, limit: limit + 3 }) + ]); + if (asTyped.status === 'rejected' && asMix.status === 'rejected') throw asTyped.reason; + + const seen = new Set(); + const merged = [...(asMix.value || []), ...(asTyped.value || [])].filter((track) => { + const key = track.id || track.url; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const isSession = (track) => track.live || (track.duration || 0) >= SESSION_SECONDS; + return [...merged.filter(isSession), ...merged.filter((t) => !isSession(t))].slice(0, limit + 3); +} + async function searchYtDlp(query, { source, limit }) { const prefix = source === 'soundcloud' ? 'scsearch' : 'ytsearch'; diff --git a/lib/ui.js b/lib/ui.js index c44ef8a..867e8ec 100644 --- a/lib/ui.js +++ b/lib/ui.js @@ -282,7 +282,8 @@ export class UI { const tab = (id, label) => activeTab === id ? chalk.hex(theme.accent).bold(`● ${label}`) : chalk.hex(theme.muted)(`○ ${label}`); - let out = `\n ${tab('vibes', 'vibes')} ${tab('activities', 'sessions')}\n\n`; + const prompt = `${chalk.hex(theme.accent).bold('/')} ${chalk.hex(theme.text)('describe a vibe')}${chalk.hex(theme.muted)(' e.g. james bond intro music, salsa, working')}`; + let out = `\n ${prompt}\n\n ${tab('vibes', 'vibes')} ${tab('activities', 'sessions')}\n\n`; const nameWidth = Math.max(...items.map(c => displayWidth(c.name))) + 3; for (let i = 0; i < items.length; i++) { @@ -310,7 +311,7 @@ export class UI { ['enter', 'play'], ['1-0', 'jump'], ['tab', activeTab === 'vibes' ? 'sessions' : 'vibes'], - ['/', 'search'], + ['/', 'describe a vibe'], ['f', 'favorites'], ['r', 'recents'], options.needsConnect ? ['c', 'connect spotify'] : null, @@ -372,9 +373,13 @@ export class UI { const theme = this.theme; const brand = PLATFORM_BRANDS[source]; const where = brand ? ` on ${brand.label}` : ''; - return `\n ${chalk.hex(theme.muted)(`what do you want to hear${where}? paste a link or type a vibe`)}\n ${chalk.hex(theme.accent).bold('▸')} `; + return `\n ${chalk.hex(theme.text).bold(`describe a vibe${where}`)}\n` + + ` ${chalk.hex(theme.muted)('a mood, a scene, an artist, a song, or a link. enter on empty goes back.')}\n` + + ` ${chalk.hex(theme.muted)('try: james bond intro music, salsa, working · rainy tokyo night drive · daft punk')}\n\n` + + ` ${chalk.hex(theme.accent).bold('\u25b8')} `; } + // An error that stays on screen until the next key, instead of a timed flash. notice(label, message) { const theme = this.theme; @@ -487,7 +492,7 @@ export class UI { if (results.length === 0) { out += ` ${chalk.hex(theme.muted)(options.emptyMessage || 'nothing here yet.')}\n\n`; - out += this.hints([['/', 'search'], ['b', 'back'], ['m', 'source'], ['q', 'quit']]) + '\n'; + out += this.hints([['/', 'new vibe'], ['b', 'back'], ['m', 'source'], ['q', 'quit']]) + '\n'; return out; } @@ -528,7 +533,7 @@ export class UI { ['enter', 'play'], ['↑↓', 'move'], ['f', selected?.favorite ? 'unfavorite' : 'favorite'], - ['/', 'search'], + ['/', 'new vibe'], ['b', 'back'], options.kind === 'search' ? ['m', 'try next source'] : null, ['t', 'theme'], @@ -607,7 +612,7 @@ export class UI { ['t', 'theme'], ['d', 'light/dark'], options.canOpen ? ['o', 'open'] : null, - ['/', 'search'], + ['/', 'new vibe'], ['b', 'back'], ['q', 'quit'] ]);