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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ 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.
Sonic opens on the **describe** tab: a text box where you type what you want in plain words, like a mood, a scene, an artist, or a song. `↑` `↓` fill in examples, `Esc` clears, and `Tab` moves to vibes and sessions, where the single-key shortcuts live. `/` anywhere brings you back to the box. 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

Expand Down
142 changes: 106 additions & 36 deletions bin/sonic.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env node

import readline from 'node:readline';
import { readFileSync } from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';

Expand Down Expand Up @@ -76,7 +75,7 @@ let results = [];
let currentTrack = null;
let currentQuery = '';
let currentSearchOptions = {};
let state = 'idle'; // idle | picker | searching | results | playing | search_input
let state = 'idle'; // idle | picker | searching | results | playing | connect
let source = store.config.defaultSource;
let queue = [];
let history = [];
Expand Down Expand Up @@ -115,7 +114,7 @@ function activePlayer() {

const LINK_TRACK_LIMIT = 100;

let pickerTab = 'vibes';
let pickerTab = 'describe';
let vibesCursor = 0;
let activityCursor = 0;
let resultsCursor = 0;
Expand Down Expand Up @@ -225,9 +224,6 @@ function cycleThemeInApp(direction = 1) {
return;
}

if (state === 'search_input') {
promptSearch();
}
}

function enableRawKeys() {
Expand Down Expand Up @@ -300,17 +296,32 @@ function renderPlaying() {
}

function renderPicker() {
ui.clear();
process.stdout.write(ui.header(source));
const cursor = pickerTab === 'vibes' ? vibesCursor : activityCursor;
const items = pickerTab === 'vibes' ? CHANNELS : ACTIVITIES;
const needsConnect = source === 'spotify' && !spotifyReady();
process.stdout.write(ui.picker(items, cursor, pickerTab, {
const library = {
source,
favoritesCount: store.favorites.length,
recentsCount: store.recents.length,
sourceNote: needsConnect ? 'not connected yet · press c to connect' : null,
sourceNote: needsConnect ? 'not connected yet · tab, then c to connect' : null,
needsConnect
};
if (pickerTab === 'describe') {
const screen = ui.header(source) + ui.describeTab({
text: vibeText,
examples: VIBE_EXAMPLES,
exampleIdx,
notice: vibeNotice,
...library
});
process.stdout.write('\x1b[H' + screen.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
return;
}
ui.clear();
process.stdout.write(ui.header(source));
const cursor = pickerTab === 'vibes' ? vibesCursor : activityCursor;
const items = pickerTab === 'vibes' ? CHANNELS : ACTIVITIES;
process.stdout.write(ui.picker(items, cursor, pickerTab, {
...library,
sourceNote: needsConnect ? 'not connected yet · press c to connect' : null
}));
}

Expand Down Expand Up @@ -787,29 +798,85 @@ async function rerunCurrentQueryWithNextSource() {
}
}

// Every "type something" path lands in the describe tab's text box.
function promptSearch(notice = '') {
state = 'search_input';
ui.stopVisualizer();
disableRawKeys();
ui.showCursor();
vibeNotice = notice;
pickerTab = 'describe';
showPicker();
}

ui.clear();
process.stdout.write(ui.header(source) + notice);
// ─── Describe tab ───────────────────────────────────────────────────────────
// A text box on the home screen. Letters type here, so shortcuts live on the
// vibes and sessions tabs.

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const VIBE_EXAMPLES = [
'james bond intro music, salsa, working',
'rainy tokyo night drive',
'sunday morning coffee jazz',
'daft punk'
];

rl.question(ui.searchPrompt(source), (answer) => {
rl.close();
const query = answer.trim();
if (!query) {
showPicker();
return;
}
void doSearch(query);
});
let vibeText = '';
let vibeNotice = '';
let exampleIdx = -1;

const PICKER_TABS = ['describe', 'vibes', 'activities'];

function switchPickerTab(direction = 1) {
const idx = PICKER_TABS.indexOf(pickerTab);
pickerTab = PICKER_TABS[(idx + direction + PICKER_TABS.length) % PICKER_TABS.length];
renderPicker();
}

async function handleDescribeKey(key) {
if (key === '\t') return switchPickerTab(1);
if (key === '\x1b[Z') return switchPickerTab(-1);

if (key === '\r' || key === '\n') {
const query = vibeText.trim();
if (!query) return;
vibeNotice = '';
await doSearch(query);
return;
}

if (key === '\x7f' || key === '\b') {
vibeText = [...vibeText].slice(0, -1).join('');
exampleIdx = -1;
renderPicker();
return;
}

if (key === '\x1b' || key === '\x15') {
vibeText = '';
vibeNotice = '';
exampleIdx = -1;
renderPicker();
return;
}

if (key === '\x1b[A' || key === '\x1b[B') {
const step = key === '\x1b[B' ? 1 : -1;
exampleIdx = (exampleIdx + step + VIBE_EXAMPLES.length) % VIBE_EXAMPLES.length;
vibeText = VIBE_EXAMPLES[exampleIdx];
renderPicker();
return;
}

// Arrow left/right switch tabs only when the box is empty.
if ((key === '\x1b[C' || key === '\x1b[D') && !vibeText) {
return switchPickerTab(key === '\x1b[C' ? 1 : -1);
}
if (key.startsWith('\x1b')) return;

// Typed keys and pasted text; newlines inside a paste submit nothing.
const typed = key.replace(/[\x00-\x1f\x7f]/g, '');
if (typed) {
vibeText += typed;
exampleIdx = -1;
renderPicker();
}
}

process.stdin.on('data', async (input) => {
Expand All @@ -827,12 +894,14 @@ process.stdin.on('data', async (input) => {
return;
}

if (state === 'picker' && pickerTab === 'describe') {
await handleDescribeKey(key);
return;
}

if (state === 'picker') {
if (key === '\t' || isLeft(key) || isRight(key)) {
pickerTab = pickerTab === 'vibes' ? 'activities' : 'vibes';
renderPicker();
return;
}
if (key === '\t' || isRight(key)) return switchPickerTab(1);
if (key === '\x1b[Z' || isLeft(key)) return switchPickerTab(-1);

const items = pickerTab === 'vibes' ? CHANNELS : ACTIVITIES;
const cursor = pickerTab === 'vibes' ? vibesCursor : activityCursor;
Expand Down Expand Up @@ -868,7 +937,8 @@ process.stdin.on('data', async (input) => {
}

if (key === '/') {
promptSearch();
pickerTab = 'describe';
renderPicker();
return;
}

Expand Down
90 changes: 66 additions & 24 deletions lib/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,73 @@ export class UI {

// ─── Channel Picker UI ──────────────────────────────────────────────────

picker(items, cursorIdx, activeTab, options = {}) {
pickerTabs(activeTab) {
const theme = this.theme;
const tab = (id, label) => activeTab === id
? chalk.hex(theme.accent).bold(`● ${label}`)
: chalk.hex(theme.muted)(`○ ${label}`);
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`;
return `\n ${tab('describe', 'describe')} ${tab('vibes', 'vibes')} ${tab('activities', 'sessions')}\n\n`;
}

libraryLine(options) {
const theme = this.theme;
const brand = PLATFORM_BRANDS[options.source] || PLATFORM_BRANDS.youtube;
const library = [
`${chalk.hex(brand.color)(brand.icon)} ${chalk.hex(theme.muted)(`playing from ${brand.label}`)}`,
chalk.hex(theme.favorite)(`♥ ${options.favoritesCount || 0}`),
chalk.hex(theme.muted)(`↺ ${options.recentsCount || 0} recent`)
];
if (options.sourceNote) library.push(chalk.hex(theme.muted)(options.sourceNote));
return ` ${library.join(chalk.hex(theme.muted)(' '))}\n`;
}

// The first tab: a text box for describing what you want to hear.
describeTab({ text, examples, exampleIdx, notice, ...options }) {
const theme = this.theme;
const placeholder = ' a mood, a scene, an artist, a song, or a link';
let out = this.pickerTabs('describe');
out += ` ${chalk.hex(theme.text).bold('describe your vibe')}\n`;

// Long text scrolls left so the caret stays inside the box.
const inner = Math.max(24, Math.min(this.contentWidth, 76) - 4);
let visible = '';
let used = 0;
for (const ch of [...text].reverse()) {
const w = displayWidth(ch);
if (used + w > inner - 1) break;
visible = ch + visible;
used += w;
}
const caret = chalk.hex(theme.accent).inverse(' ');
const body = text
? chalk.hex(theme.text)(visible) + caret
: caret + chalk.hex(theme.muted)(fit(placeholder, inner - 1).trimEnd());
const filled = text ? used + 1 : 1 + displayWidth(fit(placeholder, inner - 1).trimEnd());
const edge = (s) => chalk.hex(theme.accent)(s);
out += ` ${edge('╭' + '─'.repeat(inner + 2) + '╮')}\n`;
out += ` ${edge('│')} ${body}${' '.repeat(Math.max(0, inner - filled))} ${edge('│')}\n`;
out += ` ${edge('╰' + '─'.repeat(inner + 2) + '╯')}\n\n`;

examples.forEach((example, i) => {
const label = i === 0 ? chalk.hex(theme.muted)('try') : ' ';
const line = i === exampleIdx ? chalk.hex(theme.text).bold(example) : chalk.hex(theme.muted)(example);
out += ` ${label} ${line}\n`;
});
if (notice) out += notice;
out += `\n${this.libraryLine(options)}\n`;
out += this.hints([
['enter', text ? 'play this vibe' : 'play'],
['↑↓', 'examples'],
['esc', 'clear'],
['tab', 'vibes & shortcuts'],
['ctrl-c', 'quit']
]) + '\n';
return out;
}

picker(items, cursorIdx, activeTab, options = {}) {
const theme = this.theme;
let out = this.pickerTabs(activeTab);

const nameWidth = Math.max(...items.map(c => displayWidth(c.name))) + 3;
for (let i = 0; i < items.length; i++) {
Expand All @@ -299,19 +359,12 @@ export class UI {
out += ` ${pointer} ${key} ${c.icon}${col(12)}${name}${desc}\n`;
}

const brand = PLATFORM_BRANDS[options.source] || PLATFORM_BRANDS.youtube;
const library = [
`${chalk.hex(brand.color)(brand.icon)} ${chalk.hex(theme.muted)(`playing from ${brand.label}`)}`,
chalk.hex(theme.favorite)(`♥ ${options.favoritesCount || 0}`),
chalk.hex(theme.muted)(`↺ ${options.recentsCount || 0} recent`)
];
if (options.sourceNote) library.push(chalk.hex(theme.muted)(options.sourceNote));
out += `\n ${library.join(chalk.hex(theme.muted)(' '))}\n\n`;
out += `\n${this.libraryLine(options)}\n`;
out += this.hints([
['enter', 'play'],
['1-0', 'jump'],
['tab', activeTab === 'vibes' ? 'sessions' : 'vibes'],
['/', 'describe a vibe'],
['tab', activeTab === 'vibes' ? 'sessions' : 'describe'],
['/', 'describe'],
['f', 'favorites'],
['r', 'recents'],
options.needsConnect ? ['c', 'connect spotify'] : null,
Expand Down Expand Up @@ -369,17 +422,6 @@ export class UI {

// ─── Search / Results UI ─────────────────────────────────────────────────

searchPrompt(source) {
const theme = this.theme;
const brand = PLATFORM_BRANDS[source];
const where = brand ? ` on ${brand.label}` : '';
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
Loading