diff --git a/README.md b/README.md index c74bac1..af4ee0f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/sonic.js b/bin/sonic.js index ae108bb..a1d06ff 100755 --- a/bin/sonic.js +++ b/bin/sonic.js @@ -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'; @@ -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 = []; @@ -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; @@ -225,9 +224,6 @@ function cycleThemeInApp(direction = 1) { return; } - if (state === 'search_input') { - promptSearch(); - } } function enableRawKeys() { @@ -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 })); } @@ -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) => { @@ -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; @@ -868,7 +937,8 @@ process.stdin.on('data', async (input) => { } if (key === '/') { - promptSearch(); + pickerTab = 'describe'; + renderPicker(); return; } diff --git a/lib/ui.js b/lib/ui.js index 867e8ec..073bdaa 100644 --- a/lib/ui.js +++ b/lib/ui.js @@ -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++) { @@ -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, @@ -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;