Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 | — |
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion bin/sonic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
35 changes: 33 additions & 2 deletions lib/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
17 changes: 11 additions & 6 deletions lib/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -607,7 +612,7 @@ export class UI {
['t', 'theme'],
['d', 'light/dark'],
options.canOpen ? ['o', 'open'] : null,
['/', 'search'],
['/', 'new vibe'],
['b', 'back'],
['q', 'quit']
]);
Expand Down
Loading