diff --git a/AGENTS.md b/AGENTS.md
index 5890e26..483755c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,7 +15,8 @@ Make Sonic feel like a real terminal product, not a code sketch. Favor changes t
- expressive terminal UI and visualizers
- switchable curated themes with a persisted default
- SoundCloud and Mixcloud search
-- direct YouTube, SoundCloud, and Mixcloud link playback
+- direct YouTube, SoundCloud, Mixcloud, and Spotify link playback
+- optional Spotify remote control through `spogo` (audio plays in a Spotify app, not mpv)
## Commands
@@ -32,6 +33,7 @@ System dependencies:
```bash
brew install yt-dlp mpv
+brew install steipete/tap/spogo # optional, for Spotify
```
## File Map
@@ -40,6 +42,7 @@ brew install yt-dlp mpv
bin/sonic.js CLI state machine
lib/search.js search adapter
lib/player.js mpv controller
+lib/spotify.js Spotify search and remote control via spogo
lib/channels.js content presets
lib/ui.js TUI rendering
lib/system.js system checks and safe URL opening
diff --git a/README.md b/README.md
index b53a30d..c2f54be 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,13 @@
SONIC is a vibes-based CLI music player to join alongside your other terminal tabs
- Supports YouTube, Mixcloud, and Soundcloud as of now.
+ Supports YouTube, SoundCloud, Mixcloud, and Spotify.
-
+
## Quick Start
@@ -58,6 +58,7 @@ sonic # open the picker
sonic "deep house" # search and play
sonic --soundcloud "liquid dnb" # search SoundCloud
sonic --mixcloud "techno mix" # search Mixcloud
+sonic --spotify "daft punk" # search Spotify
sonic "https://youtu.be/..." # play a link (quote it)
sonic doctor # check setup
sonic --help # full help
@@ -71,6 +72,7 @@ sonic --help # full help
| `Space` | — | Pause / resume |
| `/` | Search | Search (stops playback) |
| `j` `k` / arrows | Move through lists | — |
+| `1`–`0` | Quick pick a vibe | — |
| `Tab` / `Left` `Right` | Switch tabs | — |
| `n` / `p` | — | Next / previous track |
| `f` | Open favorites | Favorite track |
@@ -97,11 +99,28 @@ Five modes: **Bars**, **Tape**, **Wave**, **Rain**, **Flame**. Press `v` during
## Sources
-YouTube, SoundCloud, and Mixcloud. All use `mpv` for playback and require no auth. Press `m` to cycle.
+YouTube, SoundCloud, and Mixcloud play through `mpv` and need no account. Press `m` to cycle. Live streams show a **LIVE** badge instead of a fake length.
+
+### Spotify
+
+Spotify is optional. Sonic acts as a remote: audio plays in a Spotify app (desktop, phone, or web player), and Sonic searches, starts, skips, seeks, and shows what is playing.
+
+```bash
+brew install steipete/tap/spogo # Spotify CLI that Sonic drives
+# log in at https://open.spotify.com in Chrome, then:
+spogo auth import --browser chrome # one-time; copies your Spotify session cookies
+sonic --spotify "daft punk"
+```
+
+- **Vibes and sessions** open Spotify playlists. **Search** finds tracks. Spotify links (`open.spotify.com/...` or `spotify:` URIs) play directly.
+- **How auth works:** [spogo](https://github.com/steipete/spogo) uses your own Spotify web session cookies, stored locally. Sonic never sees or stores your password, and no developer app is needed.
+- **Premium:** Spotify only allows remote playback control for Premium accounts. On macOS with the Spotify desktop app open, spogo can drive the app directly instead.
+- Spotify shows up in the `m` cycle once `spogo` is installed. `sonic doctor` reports whether you are logged in.
+- Unofficial: this uses Spotify's web player endpoints the same way your browser does. Use it with your own account.
## Requirements
-**Node.js** 22+ · **yt-dlp** · **mpv** · macOS or Linux
+**Node.js** 22+ · **yt-dlp** · **mpv** · macOS or Linux · optional: **spogo** for Spotify
## Contributing
diff --git a/assets/demo.gif b/assets/demo.gif
index cd3c756..edd171f 100644
Binary files a/assets/demo.gif and b/assets/demo.gif differ
diff --git a/assets/demo.tape b/assets/demo.tape
index b54935d..a01ec8a 100644
--- a/assets/demo.tape
+++ b/assets/demo.tape
@@ -1,14 +1,15 @@
# Sonic CLI Demo
-# Run: vhs assets/demo.tape
+# Run from the repo root: vhs assets/demo.tape
+# Tip: set "volume": 0 in ~/.config/sonic/store.json while recording.
Output assets/demo.gif
Set Shell zsh
-Set FontFamily "JetBrains Mono"
+Set FontFamily "Menlo"
Set FontSize 15
-Set Width 900
-Set Height 550
-Set Padding 16
+Set Width 1000
+Set Height 820
+Set Padding 20
Set Framerate 15
Set TypingSpeed 50ms
Set Theme "Catppuccin Mocha"
@@ -23,42 +24,40 @@ Down
Sleep 400ms
Down
Sleep 400ms
-Down
-Sleep 400ms
+Up
+Sleep 500ms
-# Switch tab and back
+# Peek at sessions and back
Tab
-Sleep 1s
+Sleep 1.2s
Tab
Sleep 800ms
-# Cycle themes
-Type "t"
-Sleep 1s
-Type "t"
-Sleep 1s
-
-# Search
-Type "/"
-Sleep 500ms
-Type "lofi chill beats"
-Sleep 300ms
-Enter
-Sleep 4s
+# Quick pick: synthwave
+Type "8"
+Sleep 8s
-# Play first result
+# Play the first mix
Enter
-Sleep 8s
+Sleep 10s
-# Switch visualizer
+# Visualizers
Type "v"
-Sleep 3s
+Sleep 2.5s
+Type "v"
+Sleep 2.5s
+Type "v"
+Sleep 2.5s
Type "v"
-Sleep 3s
+Sleep 2.5s
-# Cycle theme during playback
+# Themes
Type "t"
-Sleep 2.5s
+Sleep 2s
+Type "t"
+Sleep 2s
+Type "v"
+Sleep 2s
# Quit
Type "q"
diff --git a/bin/sonic.js b/bin/sonic.js
index 8b87a30..c7ec199 100755
--- a/bin/sonic.js
+++ b/bin/sonic.js
@@ -2,10 +2,10 @@
import readline from 'node:readline';
import { readFileSync } from 'node:fs';
-import chalk from 'chalk';
import { search, SOURCES, parseSourceUrl, resolveUrl } from '../lib/search.js';
import { Player } from '../lib/player.js';
+import { SpotifyPlayer, hasSpogo } from '../lib/spotify.js';
import { UI } from '../lib/ui.js';
import {
CHANNELS, ACTIVITIES,
@@ -57,12 +57,16 @@ if (doctorMode) {
const ui = new UI();
const player = new Player();
+const spotifyPlayer = new SpotifyPlayer();
let results = [];
let currentTrack = null;
let currentQuery = '';
+let currentSearchOptions = {};
let state = 'idle'; // idle | picker | searching | results | playing | search_input
-let source = store.config.defaultSource;
+let source = availableSources().includes(store.config.defaultSource)
+ ? store.config.defaultSource
+ : 'youtube';
let queue = [];
let history = [];
let resultsMeta = {
@@ -82,6 +86,22 @@ if (args.includes('--sc') || args.includes('--soundcloud')) {
if (args.includes('--mc') || args.includes('--mixcloud')) {
source = 'mixcloud';
}
+if (args.includes('--sp') || args.includes('--spotify')) {
+ source = 'spotify';
+}
+
+// Spotify joins the source cycle only once spogo is installed.
+function availableSources() {
+ return SOURCES.filter((name) => name !== 'spotify' || hasSpogo());
+}
+
+function isSpotifyTrack() {
+ return currentTrack?.source === 'spotify';
+}
+
+function activePlayer() {
+ return isSpotifyTrack() ? spotifyPlayer : player;
+}
const LINK_TRACK_LIMIT = 100;
@@ -102,7 +122,10 @@ function cleanup(exitCode = 0) {
if (player.proc) {
try { player.proc.kill('SIGKILL'); } catch {}
}
- player.stop().then(() => process.exit(exitCode)).catch(() => process.exit(exitCode));
+ Promise.all([
+ player.stop().catch(() => {}),
+ spotifyPlayer.stop().catch(() => {})
+ ]).then(() => process.exit(exitCode));
}
process.on('SIGINT', () => cleanup(0));
@@ -162,8 +185,9 @@ function setSequenceFromList(list, index) {
}
function cycleSource() {
- const idx = SOURCES.indexOf(source);
- source = SOURCES[(idx + 1) % SOURCES.length];
+ const sources = availableSources();
+ const idx = sources.indexOf(source);
+ source = sources[(idx + 1) % sources.length];
persistConfig({ defaultSource: source });
}
@@ -218,47 +242,49 @@ function isRight(key) { return key === '\x1b[C' || key === 'l'; }
function renderPlaying() {
const src = currentTrack?.source || 'youtube';
- let out = '';
- out += ui.header(src);
- out += '\n';
+ const p = activePlayer();
+ const remote = src === 'spotify';
+ const live = Boolean(currentTrack?.live);
+ const duration = live ? 0 : (p.duration || currentTrack?.duration);
+ const gap = ui.rows < 30 ? '\n' : '\n\n';
+ let top = ui.header(src) + '\n';
if (currentTrack) {
- out += ui.nowPlaying(
- currentTrack.title,
- currentTrack.channel,
- player.duration || currentTrack.duration,
- src,
- {
- favorite: currentTrack.favorite,
- queueLength: queue.length,
- historyLength: history.length
- }
- );
+ top += ui.nowPlaying(currentTrack, { device: remote ? spotifyPlayer.device : null });
}
+ top += gap;
- out += '\n\n';
- const duration = player.duration || currentTrack?.duration;
-
+ let viz;
if (ui.vizMode === 'tape') {
- out += ui.renderTape(player.position, duration);
+ viz = ui.renderTape(p.position, duration);
} else if (ui.vizMode === 'wave') {
- out += ui.renderWaveform();
+ viz = ui.renderWaveform();
} else if (ui.vizMode === 'rain') {
- out += ui.renderRain();
+ viz = ui.renderRain();
} else if (ui.vizMode === 'flame') {
- out += ui.renderFlame();
+ viz = ui.renderFlame();
} else {
- out += ui.renderBars();
+ viz = ui.renderBars();
}
- out += '\n\n';
- out += ui.progressBar(player.position, duration);
- out += '\n\n';
- out += ui.controls(player.paused, {
- queueLength: queue.length,
- canGoBack: history.length > 0,
- isFavorite: currentTrack?.favorite
- });
+ const bottom = gap
+ + ui.progressBar(p.position, duration, { live }) + '\n'
+ + ui.statusLine({
+ volume: typeof p.volume === 'number' ? p.volume : null,
+ upNext: !remote && queue.length ? queue[0].title : null
+ })
+ + gap
+ + ui.controls(p.paused, {
+ queueLength: remote ? 0 : queue.length,
+ canGoBack: remote || history.length > 0,
+ isFavorite: currentTrack?.favorite,
+ canSeek: !live,
+ canOpen: Boolean(currentTrack?.url)
+ });
+
+ // The visualizer gives up rows first so the header and controls stay on screen.
+ const fixedRows = top.split('\n').length + bottom.split('\n').length - 1;
+ const out = top + ui.fitRows(viz, ui.rows - fixedRows) + bottom;
// Flicker-free render: cursor home, clear each line's trailing chars, clear below
process.stdout.write('\x1b[H' + out.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
@@ -306,8 +332,8 @@ function showFavorites() {
title: 'favorites',
subtitle: `${store.favorites.length} saved track${store.favorites.length === 1 ? '' : 's'}`,
kind: 'favorites',
- emptyMessage: 'no favorites yet. press f while browsing or playing to save one.',
- footer: 'enter play · f unfavorite · / search · b back · m source · t theme · d mode · q quit'
+ showSource: true,
+ emptyMessage: 'no favorites yet. press f on any track to save it here.'
});
}
@@ -316,8 +342,8 @@ function showRecents() {
title: 'recently played',
subtitle: `${store.recents.length} recent track${store.recents.length === 1 ? '' : 's'}`,
kind: 'recents',
- emptyMessage: 'no recents yet. play something and it will show up here.',
- footer: 'enter play · f favorite · / search · b back · m source · t theme · d mode · q quit'
+ showSource: true,
+ emptyMessage: 'nothing played yet. pick a vibe and it shows up here.'
});
}
@@ -335,9 +361,10 @@ async function runWithLoader(tips, task) {
tipIdx = Math.min(tipIdx + 1, tips.length - 1);
}, 3000);
+ // Redraw under whichever header (full or compact) is showing.
const spin = setInterval(() => {
- ui.moveTo(4, 1);
- process.stdout.write('\x1b[2K' + ui.spinner(frame++, tips[tipIdx]));
+ const screen = ui.header(source) + '\n' + ui.spinner(frame++, tips[tipIdx]) + '\n';
+ process.stdout.write('\x1b[H' + screen.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
}, 80);
try {
@@ -352,13 +379,10 @@ async function runWithLoader(tips, task) {
}
function showLoadFailure(label, err) {
- ui.clear();
- process.stdout.write(ui.header(source));
- process.stdout.write(`\n ${chalk.red(label)} ${err.message}\n`);
- promptSearch();
+ promptSearch(ui.notice(label, err.message));
}
-async function doSearch(query) {
+async function doSearch(query, { preset = false, label = null } = {}) {
const link = parseSourceUrl(query);
if (link) {
await doOpenLink(query, link);
@@ -366,6 +390,7 @@ async function doSearch(query) {
}
currentQuery = query;
+ currentSearchOptions = { preset, label };
state = 'searching';
const outcome = await runWithLoader([
@@ -373,7 +398,7 @@ async function doSearch(query) {
'finding the best mixes...',
'curating your vibe...',
'almost there...'
- ], () => search(query, { source, limit: store.config.searchLimit }));
+ ], () => search(query, { source, limit: store.config.searchLimit, preset }));
if (!outcome.ok) {
showLoadFailure('search failed:', outcome.error);
@@ -382,10 +407,9 @@ async function doSearch(query) {
setResultsView(outcome.value, {
title: 'search results',
- subtitle: `${source} · ${query}`,
+ subtitle: `${source} · ${label || query}`,
kind: 'search',
- emptyMessage: `no ${source} results for “${query}”.`,
- footer: 'enter play · f favorite · / search · b back · m source · t theme · d mode · q quit'
+ emptyMessage: `no ${source} results for “${label || query}”. press m to try another source.`
});
}
@@ -394,6 +418,14 @@ async function doOpenLink(rawUrl, link) {
state = 'searching';
source = link.source;
+ // Spotify links play straight away; the status poll fills in the title.
+ if (link.source === 'spotify') {
+ queue = [];
+ history = [];
+ await playTrack(link.track);
+ return;
+ }
+
const outcome = await runWithLoader([
`opening ${link.source} link...`,
'resolving track info...',
@@ -417,55 +449,63 @@ async function doOpenLink(rawUrl, link) {
title: heading,
subtitle,
kind: 'link',
- emptyMessage: 'that link resolved to no playable tracks.',
- footer: 'enter play · f favorite · / search · b back · m source · t theme · d mode · q quit'
+ emptyMessage: 'that link resolved to no playable tracks.'
});
}
async function playTrack(track) {
const hydrated = hydrateTrack(track);
currentTrack = hydrated;
- addRecent(store, hydrated);
- saveAppState();
state = 'playing';
ui.hideCursor();
enableRawKeys();
+ const remote = hydrated.source === 'spotify';
let frame = 0;
- let loadingStage = 'connecting';
+ let loadingStage = remote ? 'remote' : 'connecting';
const loadingAnim = setInterval(() => {
- const content = ui.loadingScreen(frame++, hydrated, source, loadingStage);
+ const content = ui.loadingScreen(frame++, hydrated, hydrated.source, loadingStage);
process.stdout.write('\x1b[H' + content.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
}, 60);
try {
- await player.play(hydrated.url);
- loadingStage = 'buffering';
-
- await new Promise((resolve) => {
- let checks = 0;
- const poll = setInterval(() => {
- if (player.position > 0.5 || player.duration > 0) {
- clearInterval(poll);
- loadingStage = 'ready';
- setTimeout(resolve, 400);
- }
- if (++checks > 300) {
- clearInterval(poll);
- resolve();
- }
- }, 100);
- });
+ if (remote) {
+ await player.stop();
+ await spotifyPlayer.play(hydrated);
+ } else {
+ await spotifyPlayer.stop();
+ await player.play(hydrated.url);
+ loadingStage = 'buffering';
+
+ await new Promise((resolve) => {
+ let checks = 0;
+ const poll = setInterval(() => {
+ if (player.position > 0.5 || player.duration > 0) {
+ clearInterval(poll);
+ loadingStage = 'ready';
+ setTimeout(resolve, 400);
+ }
+ if (++checks > 300) {
+ clearInterval(poll);
+ resolve();
+ }
+ }, 100);
+ });
+ }
clearInterval(loadingAnim);
+ if (state !== 'playing') return;
+ // Spotify adds its own recents from the status poll, with the real title.
+ if (!remote) {
+ addRecent(store, hydrated);
+ saveAppState();
+ }
ui.startVisualizer(() => renderPlaying());
} catch (err) {
clearInterval(loadingAnim);
- ui.clear();
- process.stdout.write(ui.header(source));
- process.stdout.write(`\n ${chalk.red('playback error:')} ${err.message}\n`);
- setTimeout(() => promptSearch(), 2000);
+ if (state !== 'playing') return;
+ promptSearch(ui.notice(remote ? 'spotify:' : 'playback failed:', err.message));
}
}
@@ -476,6 +516,10 @@ async function playSelectedResult(index) {
}
async function playNextTrack() {
+ if (isSpotifyTrack()) {
+ await spotifyPlayer.next();
+ return;
+ }
if (!queue.length) return;
if (currentTrack) history.push(hydrateTrack(currentTrack));
const nextTrack = queue.shift();
@@ -483,6 +527,10 @@ async function playNextTrack() {
}
async function playPreviousTrack() {
+ if (isSpotifyTrack()) {
+ await spotifyPlayer.previous();
+ return;
+ }
if (!history.length) return;
if (currentTrack) queue.unshift(hydrateTrack(currentTrack));
const previousTrack = history.pop();
@@ -493,7 +541,7 @@ async function rerunCurrentQueryWithNextSource() {
cycleSource();
if (resultsMeta.kind === 'search' && currentQuery) {
- await doSearch(currentQuery);
+ await doSearch(currentQuery, currentSearchOptions);
return;
}
@@ -507,25 +555,25 @@ async function rerunCurrentQueryWithNextSource() {
}
}
-function promptSearch() {
+function promptSearch(notice = '') {
state = 'search_input';
ui.stopVisualizer();
disableRawKeys();
ui.showCursor();
ui.clear();
- process.stdout.write(ui.header(source) + '\n');
+ process.stdout.write(ui.header(source) + notice);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
- rl.question(ui.searchPrompt(), (answer) => {
+ rl.question(ui.searchPrompt(source), (answer) => {
rl.close();
const query = answer.trim();
if (!query) {
- promptSearch();
+ showPicker();
return;
}
void doSearch(query);
@@ -567,14 +615,14 @@ process.stdin.on('data', async (key) => {
if (key === '\r' || key === '\n') {
const item = items[cursor];
const query = pickerTab === 'vibes' ? item.query : pickActivityQuery(item);
- await doSearch(query);
+ await doSearch(query, { preset: true, label: item.name });
return;
}
const match = items.find((item) => item.key === key);
if (match) {
const query = pickerTab === 'vibes' ? match.query : pickActivityQuery(match);
- await doSearch(query);
+ await doSearch(query, { preset: true, label: match.name });
return;
}
@@ -705,32 +753,42 @@ process.stdin.on('data', async (key) => {
}
if (state === 'playing') {
+ const p = activePlayer();
+ const remote = isSpotifyTrack();
+
+ // Spotify commands go over the network; show failures instead of crashing.
+ const run = async (task) => {
+ try {
+ await task();
+ } catch (err) {
+ if (remote) {
+ ui.stopVisualizer();
+ promptSearch(ui.notice('spotify:', err.message));
+ }
+ }
+ };
+
if (key === ' ') {
- await player.togglePause();
+ await run(() => p.togglePause());
return;
}
if (key === '/') {
ui.stopVisualizer();
- await player.stop();
+ await p.stop();
promptSearch();
return;
}
if (key === 'b') {
ui.stopVisualizer();
- await player.stop();
+ await p.stop();
showPicker();
return;
}
- if (key === '[') {
- await player.seekRelative(-10);
- return;
- }
-
- if (key === ']') {
- await player.seekRelative(10);
+ if ((key === '[' || key === ']') && !currentTrack?.live) {
+ await run(() => p.seekRelative(key === '[' ? -10 : 10));
return;
}
@@ -759,12 +817,12 @@ process.stdin.on('data', async (key) => {
}
if (key === 'n') {
- await playNextTrack();
+ await run(() => playNextTrack());
return;
}
if (key === 'p') {
- await playPreviousTrack();
+ await run(() => playPreviousTrack());
return;
}
@@ -777,16 +835,11 @@ process.stdin.on('data', async (key) => {
return;
}
- if (key === '-') {
- await player.setVolume(player.volume - 10);
- persistConfig({ volume: player.volume });
- renderPlaying();
- return;
- }
-
- if (key === '=' || key === '+') {
- await player.setVolume(player.volume + 10);
- persistConfig({ volume: player.volume });
+ if (key === '-' || key === '=' || key === '+') {
+ const step = key === '-' ? -10 : 10;
+ const current = typeof p.volume === 'number' ? p.volume : 50;
+ await run(() => p.setVolume(current + step));
+ if (!remote) persistConfig({ volume: p.volume });
renderPlaying();
return;
}
@@ -797,10 +850,27 @@ process.stdin.on('data', async (key) => {
}
});
-player.on('pause', (paused) => {
+function onPause(paused) {
if (state !== 'playing') return;
if (paused) ui.pauseVisualizer();
else ui.resumeVisualizer();
+}
+
+player.on('pause', onPause);
+spotifyPlayer.on('pause', onPause);
+
+// Spotify moves on by itself (playlists, autoplay); follow what it plays.
+spotifyPlayer.on('track', (track) => {
+ if (state !== 'playing' || !isSpotifyTrack() || !track) return;
+ currentTrack = hydrateTrack({ ...track, source: 'spotify' });
+ addRecent(store, currentTrack);
+ saveAppState();
+});
+
+process.stdout.on('resize', () => {
+ if (state === 'picker') renderPicker();
+ else if (state === 'results') renderResultsList();
+ else if (state === 'playing') renderPlaying();
});
player.on('ended', async () => {
@@ -837,7 +907,7 @@ async function main() {
if (queryArgs) {
const channel = resolveChannel(queryArgs);
enableRawKeys();
- await doSearch(channel ? channel.query : queryArgs);
+ await doSearch(channel ? channel.query : queryArgs, { preset: Boolean(channel), label: channel?.name });
return;
}
diff --git a/install.sh b/install.sh
index bf823b5..8c99e25 100755
--- a/install.sh
+++ b/install.sh
@@ -117,3 +117,5 @@ echo -e " Run: ${BOLD}sonic${RESET} — open the picker"
echo -e " Run: ${BOLD}sonic \"lofi\"${RESET} — search and play"
echo -e " Run: ${BOLD}sonic doctor${RESET} — verify setup"
echo ""
+echo -e " ${DIM}Spotify (optional): brew install steipete/tap/spogo && spogo auth import --browser chrome${RESET}"
+echo ""
diff --git a/lib/player.js b/lib/player.js
index c488b97..a2c7572 100644
--- a/lib/player.js
+++ b/lib/player.js
@@ -31,7 +31,7 @@ export class Player extends EventEmitter {
this._cleanupSocketFile();
- this.proc = spawn('mpv', [
+ const proc = spawn('mpv', [
'--no-video',
'--really-quiet',
'--no-terminal',
@@ -40,8 +40,12 @@ export class Player extends EventEmitter {
'--media-controls=yes',
url
], { stdio: 'ignore', detached: false });
+ this.proc = proc;
- this.proc.on('exit', () => {
+ // A stopped mpv exits after the next one starts; only the current
+ // process may end playback, or skipping a track skips two.
+ proc.on('exit', () => {
+ if (this.proc !== proc) return;
this.playing = false;
this.proc = null;
this._cleanupSocketFile();
@@ -51,7 +55,7 @@ export class Player extends EventEmitter {
}
});
- this.proc.on('error', (err) => {
+ proc.on('error', (err) => {
this.emit('error', err);
});
diff --git a/lib/search.js b/lib/search.js
index 6270b3e..1ab3ecf 100644
--- a/lib/search.js
+++ b/lib/search.js
@@ -1,9 +1,10 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
+import { parseSpotifyLink, searchSpotify } from './spotify.js';
const exec = promisify(execFile);
-export const SOURCES = ['youtube', 'soundcloud', 'mixcloud'];
+export const SOURCES = ['youtube', 'soundcloud', 'mixcloud', 'spotify'];
const SOURCE_HOSTS = [
{ source: 'youtube', pattern: /^(www\.|m\.|music\.)?(youtube\.com|youtube-nocookie\.com|youtu\.be)$/ },
@@ -15,6 +16,10 @@ const SOURCE_HOSTS = [
// (including plain search text, which is the common case).
export function parseSourceUrl(value) {
const trimmed = String(value || '').trim();
+ const spotify = parseSpotifyLink(trimmed);
+ if (spotify) {
+ return { url: new URL(spotify.url), source: 'spotify', track: spotify };
+ }
if (!/^https?:\/\//i.test(trimmed)) {
return null;
}
@@ -61,10 +66,13 @@ function hasSingleTrackFallback(url, source) {
export async function resolveUrl(value, { limit = 100 } = {}) {
const link = parseSourceUrl(value);
if (!link) {
- throw new Error('unsupported link — sonic plays youtube, soundcloud, and mixcloud urls');
+ throw new Error('unsupported link — sonic plays youtube, soundcloud, mixcloud, and spotify urls');
}
const { url, source } = link;
+ if (source === 'spotify') {
+ return { tracks: [link.track], source, kind: link.track.kind, note: null };
+ }
const target = url.toString();
if (isPlaylistUrl(url, source)) {
@@ -146,26 +154,36 @@ function cleanYtDlpError(err) {
return err?.message || 'yt-dlp failed';
}
-function toTrack(data, source) {
- const url = data.webpage_url
- || data.url
+function toTrack(data, source, { preferWebpage = true } = {}) {
+ const url = (preferWebpage ? data.webpage_url || data.url : data.url || data.webpage_url)
|| (source === 'youtube' && data.id ? `https://youtube.com/watch?v=${data.id}` : null);
if (!url) return null;
+ // Flat YouTube search results omit live_status; a missing duration means live.
+ const live = data.live_status === 'is_live'
+ || data.is_live === true
+ || (source === 'youtube' && data.duration == null && !data.live_status);
return {
title: data.title || 'Untitled',
url,
- duration: data.duration,
+ duration: live ? null : data.duration,
channel: data.channel || data.uploader || '',
id: data.id,
+ live,
source
};
}
-export async function search(query, { source = 'youtube', limit = 5 } = {}) {
+// 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' })
+ : searchSpotify(query, { limit, kind: 'track' });
+ }
if (source === 'mixcloud') {
- return searchMixcloud(query, limit);
+ return searchMixcloud(preset ? shortPresetQuery(query) : query, limit);
}
return searchYtDlp(query, { source, limit });
}
@@ -184,26 +202,19 @@ async function searchYtDlp(query, { source, limit }) {
.trim()
.split('\n')
.filter(Boolean)
- .map(line => {
- const data = JSON.parse(line);
- const url = data.url
- || data.webpage_url
- || (source === 'youtube' && data.id ? `https://youtube.com/watch?v=${data.id}` : null);
-
- if (!url) return null;
-
- return {
- title: data.title,
- url,
- duration: data.duration,
- channel: data.channel || data.uploader || '',
- id: data.id,
- source
- };
- })
+ .map(line => toTrack(JSON.parse(line), source, { preferWebpage: false }))
.filter(Boolean);
}
+// Preset queries are tuned for long YouTube mixes; Spotify and Mixcloud
+// search want the genre words only.
+function shortPresetQuery(query) {
+ return query
+ .replace(/\b(\d+\s*hours?|mix|playlist|instrumental|filthy drops)\b/gi, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
async function searchMixcloud(query, limit) {
const url = new URL('https://api.mixcloud.com/search/');
url.searchParams.set('q', query);
@@ -230,7 +241,7 @@ async function searchMixcloud(query, limit) {
}
export function formatDuration(seconds) {
- if (!seconds) return '??:??';
+ if (!seconds) return '--:--';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
diff --git a/lib/spotify.js b/lib/spotify.js
new file mode 100644
index 0000000..d9dcd98
--- /dev/null
+++ b/lib/spotify.js
@@ -0,0 +1,223 @@
+import { execFile, spawnSync } from 'node:child_process';
+import { promisify } from 'node:util';
+import { EventEmitter } from 'node:events';
+
+const exec = promisify(execFile);
+
+// Spotify goes through spogo (https://github.com/steipete/spogo). Sonic is a
+// remote control here: audio plays in a Spotify app (desktop, phone, or web
+// player), and sonic searches, starts, and steers it.
+
+export const SPOTIFY_SETUP = [
+ 'spotify needs spogo and a one-time login:',
+ ' brew install steipete/tap/spogo',
+ ' log in at https://open.spotify.com in chrome',
+ ' spogo auth import --browser chrome',
+ 'keep a spotify app open (desktop, phone, or web player) so there is a device to play on.'
+].join('\n');
+
+export class SpotifySetupError extends Error {}
+
+let spogoChecked = null;
+
+export function hasSpogo() {
+ if (spogoChecked === null) {
+ const result = spawnSync('spogo', ['--version'], { encoding: 'utf8' });
+ spogoChecked = !result.error && result.status === 0;
+ }
+ return spogoChecked;
+}
+
+async function spogo(args, { timeout = 15000 } = {}) {
+ if (!hasSpogo()) throw new SpotifySetupError(SPOTIFY_SETUP);
+
+ try {
+ const { stdout } = await exec('spogo', [...args, '--no-input', '--no-color'], { timeout });
+ return stdout;
+ } catch (err) {
+ throw toSpotifyError(err);
+ }
+}
+
+async function spogoJson(args, options) {
+ const stdout = await spogo([...args, '--json'], options);
+ return stdout.trim() ? JSON.parse(stdout) : null;
+}
+
+function toSpotifyError(err) {
+ const raw = String(err?.stderr || err?.message || 'spogo failed').trim();
+ const line = raw.split('\n').map(l => l.trim()).filter(Boolean).pop() || raw;
+ if (/cookie|unauthori[sz]ed|401|not logged|auth/i.test(raw)) {
+ return new SpotifySetupError(`spotify is not connected (${line}).\n${SPOTIFY_SETUP}`);
+ }
+ if (/no active device|no device|device not found|404/i.test(raw)) {
+ return new Error('no spotify device found. open spotify on this mac, your phone, or open.spotify.com, then try again.');
+ }
+ if (/premium/i.test(raw)) {
+ return new Error('spotify says this needs premium. on macOS, open the spotify desktop app and sonic will drive it directly.');
+ }
+ return new Error(line.replace(/^error:\s*/i, ''));
+}
+
+function toTrack(item, kind) {
+ if (!item?.id && !item?.url) return null;
+ const type = item.type || kind;
+ return {
+ title: item.name || 'Untitled',
+ url: item.url || `https://open.spotify.com/${type}/${item.id}`,
+ duration: item.duration_ms ? item.duration_ms / 1000 : null,
+ channel: type === 'playlist'
+ ? (item.owner || 'playlist')
+ : (item.artists || []).join(', '),
+ id: item.id,
+ kind: type,
+ trackCount: item.total_items || item.total_tracks || null,
+ source: 'spotify'
+ };
+}
+
+// kind: 'track' for free search, 'playlist' for the long-session presets.
+export async function searchSpotify(query, { limit = 5, kind = 'track' } = {}) {
+ const data = await spogoJson(['search', kind, query, '--limit', String(limit)]);
+ return (data?.items || []).map(item => toTrack(item, kind)).filter(Boolean);
+}
+
+const SPOTIFY_LINK = /^(?:https?:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?|spotify:)(track|album|playlist|artist|episode|show)[/:]([A-Za-z0-9]+)/i;
+
+export function parseSpotifyLink(value) {
+ const match = String(value || '').trim().match(SPOTIFY_LINK);
+ if (!match) return null;
+ const kind = match[1].toLowerCase();
+ return {
+ title: `spotify ${kind}`,
+ url: `https://open.spotify.com/${kind}/${match[2]}`,
+ id: match[2],
+ kind,
+ channel: '',
+ duration: null,
+ source: 'spotify'
+ };
+}
+
+const POLL_MS = 1000;
+
+export class SpotifyPlayer extends EventEmitter {
+ constructor() {
+ super();
+ this.paused = false;
+ this.playing = false;
+ this.duration = 0;
+ this.volume = null;
+ this.device = null;
+ this._progress = 0;
+ this._syncedAt = 0;
+ this._trackId = null;
+ this._timer = null;
+ }
+
+ // Interpolate between polls so the progress bar moves every frame.
+ get position() {
+ if (!this.playing) return 0;
+ const elapsed = this.paused ? 0 : (Date.now() - this._syncedAt) / 1000;
+ const pos = this._progress + elapsed;
+ return this.duration ? Math.min(pos, this.duration) : pos;
+ }
+
+ async play(track) {
+ await spogo(['play', track.url], { timeout: 20000 });
+ this.playing = true;
+ this.paused = false;
+ this._trackId = null;
+ this._progress = 0;
+ this._syncedAt = Date.now();
+ await this._sync();
+ this._startPolling();
+ }
+
+ async togglePause() {
+ const pausing = !this.paused;
+ this._setPaused(pausing);
+ try {
+ await spogo(pausing ? ['pause'] : ['play']);
+ } catch (err) {
+ this._setPaused(!pausing);
+ throw err;
+ }
+ }
+
+ async seekRelative(seconds) {
+ const target = Math.max(0, this.position + seconds);
+ this._progress = target;
+ this._syncedAt = Date.now();
+ await spogo(['seek', String(Math.round(target * 1000))]);
+ }
+
+ async setVolume(volume) {
+ this.volume = Math.max(0, Math.min(100, Math.round(volume)));
+ await spogo(['volume', String(this.volume)]);
+ }
+
+ async next() {
+ await spogo(['next']);
+ await this._sync();
+ }
+
+ async previous() {
+ await spogo(['prev']);
+ await this._sync();
+ }
+
+ async stop() {
+ this._stopPolling();
+ if (this.playing && !this.paused) {
+ try { await spogo(['pause'], { timeout: 5000 }); } catch {}
+ }
+ this.playing = false;
+ this.paused = false;
+ this.duration = 0;
+ this._trackId = null;
+ }
+
+ _setPaused(paused) {
+ if (paused === this.paused) return;
+ this._progress = this.position;
+ this._syncedAt = Date.now();
+ this.paused = paused;
+ this.emit('pause', paused);
+ }
+
+ _startPolling() {
+ this._stopPolling();
+ this._timer = setInterval(() => { void this._sync(); }, POLL_MS);
+ }
+
+ _stopPolling() {
+ if (this._timer) clearInterval(this._timer);
+ this._timer = null;
+ }
+
+ async _sync() {
+ let status;
+ try {
+ status = await spogoJson(['status'], { timeout: 5000 });
+ } catch {
+ return; // a missed poll is fine; the next one catches up
+ }
+ if (!status || !this.playing) return;
+
+ this._progress = (status.progress_ms || 0) / 1000;
+ this._syncedAt = Date.now();
+ this._setPaused(!status.is_playing);
+ if (typeof status.device?.volume_percent === 'number') this.volume = status.device.volume_percent;
+ this.device = status.device?.name || null;
+
+ const item = status.item;
+ if (item) {
+ this.duration = item.duration_ms ? item.duration_ms / 1000 : 0;
+ if (item.id !== this._trackId) {
+ this._trackId = item.id;
+ this.emit('track', toTrack(item, 'track'));
+ }
+ }
+ }
+}
diff --git a/lib/store.js b/lib/store.js
index bc2297b..cb816c5 100644
--- a/lib/store.js
+++ b/lib/store.js
@@ -3,7 +3,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { DEFAULT_THEME, normalizeThemeId } from './themes.js';
-const VALID_SOURCES = ['youtube', 'soundcloud', 'mixcloud'];
+const VALID_SOURCES = ['youtube', 'soundcloud', 'mixcloud', 'spotify'];
const APP_DIR = join(homedir(), '.config', 'sonic');
const STORE_PATH = join(APP_DIR, 'store.json');
@@ -43,6 +43,9 @@ function normalizeTrack(track, timestampField = null) {
source: VALID_SOURCES.includes(track.source) ? track.source : 'youtube'
};
+ if (track.live) normalized.live = true;
+ if (track.kind && track.kind !== 'track') normalized.kind = String(track.kind);
+
if (timestampField) {
const stamp = track[timestampField];
normalized[timestampField] = typeof stamp === 'string' && stamp ? stamp : new Date().toISOString();
diff --git a/lib/system.js b/lib/system.js
index f7d95d9..b1521f2 100644
--- a/lib/system.js
+++ b/lib/system.js
@@ -75,6 +75,21 @@ export function buildDoctorReport({ version, store, storePaths }) {
lines.push(` - ${dependency.name}: ${marker}${versionLabel}${pathLabel}`);
}
+ lines.push('');
+ lines.push('Spotify (optional):');
+ const spogo = inspectBinary('spogo');
+ if (!spogo.found) {
+ lines.push(' - spogo: not installed (brew install steipete/tap/spogo)');
+ } else {
+ lines.push(` - spogo: OK (${spogo.version})`);
+ const auth = spawnSync('spogo', ['auth', 'status', '--no-input', '--no-color'], { encoding: 'utf8', timeout: 10000 });
+ const authLine = trimFirstLine(auth.stdout || auth.stderr) || 'unknown';
+ const connected = auth.status === 0 && !/no cookies|missing|not found/i.test(authLine);
+ lines.push(connected
+ ? ` - login: ${authLine}`
+ : ' - login: not connected. log in at open.spotify.com in chrome, then run: spogo auth import --browser chrome');
+ }
+
lines.push('');
lines.push('Config:');
lines.push(` - app dir: ${storePaths.appDir}`);
@@ -104,6 +119,7 @@ Usage:
sonic ""
sonic --soundcloud
sonic --mixcloud
+ sonic --spotify
sonic doctor
Flags:
@@ -112,9 +128,10 @@ Flags:
--doctor Run setup diagnostics
--sc, --soundcloud Use SoundCloud search
--mc, --mixcloud Use Mixcloud search
+ --sp, --spotify Use Spotify search (needs spogo)
Links:
- Paste a YouTube, SoundCloud, or Mixcloud URL to play it directly.
+ Paste a YouTube, SoundCloud, Mixcloud, or Spotify URL to play it directly.
Quote the URL so the shell does not split it on & characters.
A playlist link loads the whole playlist; a watch link with a dead
list= falls back to the single linked track.
@@ -124,9 +141,16 @@ Requirements:
- yt-dlp
- mpv
+Spotify (optional):
+ - brew install steipete/tap/spogo
+ - log in at open.spotify.com in Chrome, then: spogo auth import --browser chrome
+ - keep a Spotify app open (desktop, phone, or web player); audio plays there
+ - vibes and sessions open Spotify playlists; search finds tracks
+
Picker Controls:
- Arrow keys / j k: move
- Tab / h l: switch tabs
+ - 1-0: quick pick a vibe
- /: search
- f: favorites
- r: recents
@@ -138,7 +162,7 @@ Picker Controls:
Playback Controls:
- Enter: play selection
- Space: pause / resume
- - n / p: next / previous in queue
+ - n / p: next / previous (Spotify: skips in Spotify)
- f: favorite / unfavorite current track
- t / T: next / previous theme
- d: toggle dark / light mode
diff --git a/lib/ui.js b/lib/ui.js
index 37a53a1..368d236 100644
--- a/lib/ui.js
+++ b/lib/ui.js
@@ -4,7 +4,7 @@ import { cycleTheme, detectColorMode, getTheme, normalizeThemeId } from './theme
// ─── Constants ───────────────────────────────────────────────────────────────
-const BARS_COUNT = 48;
+const MAX_BARS = 72; // visualizer width cap; actual width follows the terminal
const BAR_HEIGHT = 8; // rows above center line
const MIRROR_HEIGHT = 4; // rows below center line (reflection)
// Full block and fractional blocks for vertical resolution
@@ -133,9 +133,9 @@ function buildReel(radius, spinFrame) {
export class UI {
constructor() {
- this.bars = new Array(BARS_COUNT).fill(0);
- this.targets = new Array(BARS_COUNT).fill(0);
- this.velocities = new Array(BARS_COUNT).fill(0);
+ this.bars = new Array(MAX_BARS).fill(0);
+ this.targets = new Array(MAX_BARS).fill(0);
+ this.velocities = new Array(MAX_BARS).fill(0);
this.frameTimer = null;
this.targetTimer = null;
this.renderCallback = null;
@@ -146,9 +146,31 @@ export class UI {
this.colorMode = 'auto';
this.tapeFrame = 0;
// Rain state: 2D grid of intensities (0=empty, 1-4=intensity)
- this.rainGrid = Array.from({ length: RAIN_HEIGHT }, () => new Array(BARS_COUNT).fill(0));
+ this.rainGrid = Array.from({ length: RAIN_HEIGHT }, () => new Array(MAX_BARS).fill(0));
// Flame state: heat map
- this.flameGrid = Array.from({ length: FLAME_HEIGHT }, () => new Array(BARS_COUNT).fill(0));
+ this.flameGrid = Array.from({ length: FLAME_HEIGHT }, () => new Array(MAX_BARS).fill(0));
+ }
+
+ get cols() {
+ return process.stdout.columns || 100;
+ }
+
+ get rows() {
+ return process.stdout.rows || 40;
+ }
+
+ // Inner width for everything below the header, so rows line up.
+ get contentWidth() {
+ return Math.max(40, Math.min(this.cols - 4, 96));
+ }
+
+ get vizWidth() {
+ return Math.max(24, Math.min(this.cols - 6, MAX_BARS));
+ }
+
+ // The 6-row wordmark only fits when the playing screen still has room.
+ get compactHeader() {
+ return this.rows < 38;
}
get effectiveColorMode() {
@@ -205,63 +227,120 @@ export class UI {
header(source) {
const theme = this.theme;
+ const brand = source ? PLATFORM_BRANDS[source] : null;
+ const brandLabel = brand ? chalk.hex(brand.color).bold(`${brand.icon} ${brand.label}`) : '';
+ const sep = chalk.hex(theme.muted)(' · ');
+
+ if (this.compactHeader) {
+ const word = 'S O N I C';
+ let mark = '';
+ for (let i = 0; i < word.length; i++) {
+ const color = theme.headerGradient[Math.floor((i / word.length) * theme.headerGradient.length)];
+ mark += word[i] === ' ' ? ' ' : chalk.hex(color).bold(word[i]);
+ }
+ const parts = [mark, brandLabel, chalk.hex(theme.muted)(theme.name)].filter(Boolean);
+ return `\n ${parts.join(sep)}\n`;
+ }
+
let out = '\n';
for (const line of getHeaderLines(theme)) {
out += line + '\n';
}
- const brand = source ? PLATFORM_BRANDS[source] : null;
- if (brand) {
- out += ' '
- + chalk.hex(brand.color).bold(`${brand.icon} ${brand.label}`)
- + chalk.hex(theme.muted)(` \u00b7 ${theme.name}`)
- + chalk.hex(theme.muted)(' \u00b7 stream anything from your terminal')
- + '\n';
- } else {
- out += chalk.hex(theme.muted)(` ${theme.name} \u00b7 stream anything from your terminal`) + '\n';
- }
+ const parts = [brandLabel, chalk.hex(theme.muted)(theme.name), chalk.hex(theme.muted)('stream anything from your terminal')]
+ .filter(Boolean);
+ out += ` ${parts.join(sep)}\n`;
return out;
}
+ // Key hints: key bright, label muted, wrapped to the content width.
+ hints(pairs) {
+ const theme = this.theme;
+ const width = this.contentWidth;
+ const lines = [];
+ let line = '';
+ let lineWidth = 0;
+ for (const pair of pairs.filter(Boolean)) {
+ const [key, label] = pair;
+ const chunk = `${chalk.hex(theme.text).bold(key)} ${chalk.hex(theme.muted)(label)}`;
+ const chunkWidth = displayWidth(key) + 1 + displayWidth(label);
+ if (lineWidth && lineWidth + 3 + chunkWidth > width) {
+ lines.push(line);
+ line = '';
+ lineWidth = 0;
+ }
+ line += (lineWidth ? ' ' : '') + chunk;
+ lineWidth += (lineWidth ? 3 : 0) + chunkWidth;
+ }
+ if (line) lines.push(line);
+ return lines.map(l => ` ${l}`).join('\n');
+ }
+
// ─── Channel Picker UI ──────────────────────────────────────────────────
picker(items, cursorIdx, activeTab, options = {}) {
const theme = this.theme;
- // Tab bar
- const vibesTab = activeTab === 'vibes'
- ? chalk.hex(theme.text).bold.underline('vibes')
- : chalk.hex(theme.muted)('vibes');
- const activitiesTab = activeTab === 'activities'
- ? chalk.hex(theme.text).bold.underline('sessions')
- : chalk.hex(theme.muted)('sessions');
- let out = '\n ' + vibesTab + ' ' + chalk.hex(theme.muted)('|') + ' ' + activitiesTab + '\n\n';
+ 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 nameWidth = Math.max(...items.map(c => displayWidth(c.name))) + 3;
for (let i = 0; i < items.length; i++) {
const c = items[i];
const selected = i === cursorIdx;
- const pointer = selected ? chalk.hex(theme.accent)('\u25b8') : ' ';
+ const pointer = selected ? chalk.hex(theme.accent).bold('▸') : ' ';
+ const key = chalk.hex(selected ? theme.accent : theme.muted)(c.key || ' ');
const name = selected
- ? chalk.hex(theme.text).bold(c.name)
- : chalk.hex(theme.muted)(c.name);
- const desc = selected
- ? chalk.hex(theme.text)(c.desc)
- : chalk.hex(theme.muted)(c.desc);
- out += ` ${pointer} ${c.icon} ${name}${' '.repeat(Math.max(1, 16 - c.name.length))}${desc}\n`;
- }
- const sourceLabel = options.source || 'youtube';
- const libraryLabel = `f favorites (${options.favoritesCount || 0}) \u00b7 r recents (${options.recentsCount || 0})`;
- out += `\n ${chalk.hex(theme.muted)(`theme: ${theme.name} \u00b7 source: ${sourceLabel} \u00b7 ${libraryLabel}`)}\n`;
- out += ` ${chalk.hex(theme.muted)('enter play \u00b7 f favorite \u00b7 / search \u00b7 b back \u00b7 m source \u00b7 t theme \u00b7 d mode \u00b7 q quit')}\n`;
+ ? chalk.hex(theme.text).bold(fit(c.name, nameWidth))
+ : chalk.hex(theme.text)(fit(c.name, nameWidth));
+ const desc = chalk.hex(theme.muted)(c.desc);
+ // Emoji widths differ between terminals; jump to a fixed column after the icon.
+ 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 += this.hints([
+ ['enter', 'play'],
+ ['1-0', 'jump'],
+ ['tab', activeTab === 'vibes' ? 'sessions' : 'vibes'],
+ ['/', 'search'],
+ ['f', 'favorites'],
+ ['r', 'recents'],
+ ['m', 'source'],
+ ['t', 'theme'],
+ ['d', 'light/dark'],
+ ['q', 'quit']
+ ]) + '\n';
return out;
}
// ─── Search / Results UI ─────────────────────────────────────────────────
- searchPrompt() {
- return ` ${chalk.hex(this.theme.muted)('what do you want to hear?')}\n ${chalk.hex(this.theme.text)('\u25b8')} `;
+ searchPrompt(source) {
+ 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('▸')} `;
+ }
+
+ // An error that stays on screen until the next key, instead of a timed flash.
+ notice(label, message) {
+ const theme = this.theme;
+ const lines = String(message || '').split('\n');
+ let out = `\n ${chalk.hex(theme.favorite).bold(label)} ${chalk.hex(theme.text)(lines[0])}\n`;
+ for (const line of lines.slice(1)) out += ` ${chalk.hex(theme.muted)(line)}\n`;
+ return out;
}
spinner(frame, msg) {
- const frames = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
return ` ${chalk.hex(this.theme.spinner)(frames[frame % frames.length])} ${chalk.hex(this.theme.muted)(msg)}`;
}
@@ -286,7 +365,7 @@ export class UI {
out += '\n';
// Full-width animated waveform (fills the viz area)
- const width = BARS_COUNT;
+ const width = this.vizWidth;
const waveRows = 6;
for (let row = 0; row < waveRows; row++) {
@@ -326,12 +405,13 @@ export class UI {
connecting: `connecting to stream${dots}`,
buffering: `buffering audio${dots}`,
ready: `starting playback${dots}`,
+ remote: `asking spotify to play${dots}`,
};
const msg = stageMessages[stage] || stageMessages.connecting;
// Progress dots: show which stages are done
const stageDots = [];
- const stageOrder = ['connecting', 'buffering', 'ready'];
+ const stageOrder = stage === 'remote' ? ['remote'] : ['connecting', 'buffering', 'ready'];
const currentIdx = stageOrder.indexOf(stage);
for (let i = 0; i < stageOrder.length; i++) {
if (i < currentIdx) {
@@ -350,94 +430,142 @@ export class UI {
resultsList(results, cursorIdx = 0, options = {}) {
const theme = this.theme;
+ const width = this.contentWidth;
let out = '\n';
if (options.title) {
out += ` ${chalk.hex(theme.text).bold(options.title)}`;
if (options.subtitle) {
- out += ` ${chalk.hex(theme.muted)('\u00b7')} ${chalk.hex(theme.muted)(truncate(options.subtitle, 54))}`;
+ out += ` ${chalk.hex(theme.muted)(fit(options.subtitle, width - displayWidth(options.title) - 4).trimEnd())}`;
}
out += '\n\n';
}
if (results.length === 0) {
- out += ` ${chalk.hex(theme.muted)(options.emptyMessage || 'nothing here yet.')}\n`;
- out += `\n ${chalk.hex(theme.muted)(options.footer || '/ search \u00b7 b back \u00b7 q quit')}\n`;
+ out += ` ${chalk.hex(theme.muted)(options.emptyMessage || 'nothing here yet.')}\n\n`;
+ out += this.hints([['/', 'search'], ['b', 'back'], ['m', 'source'], ['q', 'quit']]) + '\n';
return out;
}
- for (let i = 0; i < results.length; i++) {
+ // Long lists (playlists) scroll around the cursor.
+ const room = Math.max(5, this.rows - (this.compactHeader ? 10 : 16));
+ const start = Math.max(0, Math.min(cursorIdx - Math.floor(room / 2), results.length - room));
+ const end = Math.min(results.length, start + room);
+
+ const numWidth = String(results.length).length;
+ const channelWidth = Math.min(22, Math.max(10, Math.floor(width * 0.22)));
+ const durWidth = 8;
+ const titleWidth = Math.max(16, width - numWidth - channelWidth - durWidth - 12);
+
+ if (start > 0) out += ` ${chalk.hex(theme.muted)(` ↑ ${start} more`)}\n`;
+ for (let i = start; i < end; i++) {
const r = results[i];
const selected = i === cursorIdx;
- const pointer = selected ? chalk.hex(theme.accent)('\u25b8') : ' ';
- const num = chalk.hex(theme.muted)(`${i + 1}`);
- const favorite = r.favorite ? chalk.hex(theme.favorite)('\u2665') : chalk.hex(theme.muted)('\u00b7');
- const title = selected
- ? chalk.hex(theme.text).bold(truncate(r.title, 50))
- : chalk.hex(theme.muted)(truncate(r.title, 50));
- const dur = selected
- ? chalk.hex(theme.text)(formatDuration(r.duration))
- : chalk.hex(theme.muted)(formatDuration(r.duration));
- const chan = chalk.hex(theme.muted).italic(truncate(r.channel, 20));
- out += ` ${pointer} ${num} ${favorite} ${title} ${dur} ${chan}\n`;
- }
- out += `\n ${chalk.hex(theme.muted)(options.footer || '\u2191\u2193 navigate \u00b7 enter play \u00b7 f favorite \u00b7 / search \u00b7 b back \u00b7 q quit')}\n`;
+ const brand = PLATFORM_BRANDS[r.source] || PLATFORM_BRANDS.youtube;
+ const pointer = selected ? chalk.hex(theme.accent).bold('▸') : ' ';
+ const num = chalk.hex(theme.muted)(String(i + 1).padStart(numWidth));
+ const mark = r.favorite
+ ? chalk.hex(theme.favorite)('♥')
+ : options.showSource ? chalk.hex(brand.color)(brand.icon) : chalk.hex(theme.muted)('·');
+ const titleText = fit(r.title, titleWidth);
+ const title = selected ? chalk.hex(theme.text).bold(titleText) : chalk.hex(theme.text)(titleText);
+ const length = trackLength(r).padStart(durWidth);
+ const dur = r.live
+ ? chalk.hex(theme.favorite).bold(length)
+ : chalk.hex(selected ? theme.text : theme.muted)(length);
+ const chan = chalk.hex(theme.muted)(fit(r.channel, channelWidth));
+ const durCol = 8 + numWidth + titleWidth;
+ out += ` ${pointer} ${num} ${mark} ${title}${col(durCol)} ${dur} ${chan}\n`;
+ }
+ if (end < results.length) out += ` ${chalk.hex(theme.muted)(` ↓ ${results.length - end} more`)}\n`;
+
+ const selected = results[cursorIdx];
+ out += '\n' + this.hints([
+ ['enter', 'play'],
+ ['↑↓', 'move'],
+ ['f', selected?.favorite ? 'unfavorite' : 'favorite'],
+ ['/', 'search'],
+ ['b', 'back'],
+ options.kind === 'search' ? ['m', 'try next source'] : null,
+ ['t', 'theme'],
+ ['q', 'quit']
+ ]) + '\n';
return out;
}
- nowPlaying(title, channel, duration, source = 'youtube', options = {}) {
+ nowPlaying(track, options = {}) {
const theme = this.theme;
- const t = chalk.hex(theme.text).bold(truncate(title, 48));
- const brand = PLATFORM_BRANDS[source] || PLATFORM_BRANDS.youtube;
- const srcLabel = chalk.hex(brand.color).bold(`${brand.icon} ${brand.label}`);
- const meta = chalk.hex(theme.muted)(`${formatDuration(duration)} \u00b7 ${channel}`);
- const status = [];
- if (options.favorite) status.push(chalk.hex(theme.favorite)('\u2665 favorite'));
- if (options.queueLength > 0) status.push(chalk.hex(theme.muted)(`next ${options.queueLength}`));
- if (options.historyLength > 0) status.push(chalk.hex(theme.muted)(`prev ${options.historyLength}`));
- const statusLine = status.length ? `\n ${status.join(chalk.hex(theme.muted)(' \u00b7 '))}` : '';
- return ` ${chalk.hex(theme.accent)('\u266b')} ${t}\n ${srcLabel} ${chalk.hex(theme.muted)('\u00b7')} ${meta}${statusLine}`;
- }
-
- progressBar(position, duration, width = 40) {
+ const width = this.contentWidth;
+ const brand = PLATFORM_BRANDS[track.source] || PLATFORM_BRANDS.youtube;
+ const heart = track.favorite ? ` ${chalk.hex(theme.favorite)('♥')}` : '';
+ const title = ` ${chalk.hex(theme.accent)('♫')} ${chalk.hex(theme.text).bold(fit(track.title, width - 6).trimEnd())}${heart}`;
+
+ const meta = [chalk.hex(brand.color).bold(`${brand.icon} ${brand.label}`)];
+ if (track.channel) meta.push(chalk.hex(theme.text)(fit(track.channel, 32).trimEnd()));
+ if (track.live) meta.push(chalk.hex(theme.favorite).bold('● LIVE'));
+ if (options.device) meta.push(chalk.hex(theme.muted)(`on ${options.device}`));
+ return `${title}\n ${meta.join(chalk.hex(theme.muted)(' · '))}`;
+ }
+
+ // One line under the progress bar: volume, visualizer, theme, and what's next.
+ statusLine(options = {}) {
const theme = this.theme;
- if (!duration) return '';
+ const parts = [];
+ if (typeof options.volume === 'number') {
+ const steps = 10;
+ const filled = Math.round((options.volume / 100) * steps);
+ const meter = chalk.hex(theme.accent)('━'.repeat(filled)) + chalk.hex(theme.muted)('─'.repeat(steps - filled));
+ parts.push(`${chalk.hex(theme.muted)('vol')} ${meter} ${chalk.hex(theme.muted)(String(options.volume))}`);
+ }
+ const vizColor = theme.modeColors[this.vizMode] || theme.text;
+ parts.push(chalk.hex(vizColor)(this.vizMode));
+ if (options.upNext) {
+ parts.push(`${chalk.hex(theme.muted)('next')} ${chalk.hex(theme.text)(fit(options.upNext, 34).trimEnd())}`);
+ }
+ return ` ${parts.join(chalk.hex(theme.muted)(' '))}`;
+ }
+
+ progressBar(position, duration, options = {}) {
+ const theme = this.theme;
+ const width = Math.max(20, Math.min(this.vizWidth - 18, 60));
+
+ if (options.live || !duration) {
+ const pulse = Math.floor(Date.now() / 600) % 2 === 0;
+ const dot = chalk.hex(pulse ? theme.favorite : theme.muted)('●');
+ const label = options.live ? chalk.hex(theme.favorite).bold('LIVE') : chalk.hex(theme.muted)('streaming');
+ return ` ${dot} ${label} ${chalk.hex(theme.muted)(`${formatDuration(position)} in`)}`;
+ }
+
const pct = Math.min(position / duration, 1);
const filled = Math.round(pct * width);
- const empty = width - filled;
-
- // Gradient the filled portion
let bar = '';
for (let i = 0; i < filled; i++) {
const colorIdx = Math.floor((i / width) * theme.headerGradient.length);
const color = theme.headerGradient[Math.min(colorIdx, theme.headerGradient.length - 1)];
- bar += chalk.hex(color)('\u2501');
+ bar += chalk.hex(color)('━');
}
- bar += chalk.hex(theme.muted)('\u2500'.repeat(empty));
+ const knob = filled < width ? chalk.hex(theme.text)('●') : '';
+ bar += knob + chalk.hex(theme.muted)('─'.repeat(Math.max(0, width - filled - (knob ? 1 : 0))));
- const knob = filled < width ? chalk.hex(theme.text)('\u25cf') : '';
- const time = `${formatDuration(position)} ${chalk.hex(theme.muted)('/')} ${formatDuration(duration)}`;
- return ` ${bar}${knob} ${chalk.hex(theme.muted)(time)}`;
+ const time = `${chalk.hex(theme.text)(formatDuration(position))} ${chalk.hex(theme.muted)(`/ ${formatDuration(duration)}`)}`;
+ return ` ${bar} ${time}`;
}
controls(paused, options = {}) {
- const theme = this.theme;
- const items = [
- paused ? 'space resume' : 'space pause',
- options.canGoBack ? 'p prev' : null,
- options.queueLength > 0 ? `n next(${options.queueLength})` : 'n next',
- options.isFavorite ? 'f unfavorite' : 'f favorite',
- '/ search',
- 'o open',
- 'v viz',
- 't theme',
- 'd mode',
- '- + vol',
- '[ ] seek',
- 'q quit'
- ].filter(Boolean);
- const color = theme.modeColors[this.vizMode] || theme.text;
- const vizLabel = chalk.hex(color)(`[${this.vizMode}]`);
- return ` ${chalk.hex(theme.muted)(items.join(' \u00b7 '))} ${vizLabel} ${chalk.hex(theme.muted)(`[${theme.name} \u00b7 ${this.effectiveColorMode}]`)}`;
+ return this.hints([
+ ['space', paused ? 'resume' : 'pause'],
+ ['n', options.queueLength > 0 ? `next (${options.queueLength})` : 'next'],
+ options.canGoBack ? ['p', 'prev'] : null,
+ ['f', options.isFavorite ? 'unfavorite' : 'favorite'],
+ ['- +', 'volume'],
+ options.canSeek ? ['[ ]', 'seek'] : null,
+ ['v', 'visualizer'],
+ ['t', 'theme'],
+ ['d', 'light/dark'],
+ options.canOpen ? ['o', 'open'] : null,
+ ['/', 'search'],
+ ['b', 'back'],
+ ['q', 'quit']
+ ]);
}
// ─── Visualizer Engine ───────────────────────────────────────────────────
@@ -450,9 +578,9 @@ export class UI {
// Update targets randomly at ~6-7 Hz
this.targetTimer = setInterval(() => {
if (!this.animating) return;
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
// Bell curve bias: bars near center are taller
- const center = BARS_COUNT / 2;
+ const center = this.vizWidth / 2;
const dist = Math.abs(i - center) / center;
const maxH = BAR_HEIGHT * (1 - dist * 0.35);
// Add some random energy bursts
@@ -464,7 +592,7 @@ export class UI {
// Render frames at ~30fps for smoother animation
this.frameTimer = setInterval(() => {
if (!this.animating) return;
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
// Spring physics with damping
const force = (this.targets[i] - this.bars[i]) * SPRING;
this.velocities[i] = (this.velocities[i] + force) * DAMPING;
@@ -486,7 +614,7 @@ export class UI {
if (this.targetTimer) clearInterval(this.targetTimer);
this.targetTimer = null;
// Set targets to 0 so bars settle down gracefully
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
this.targets[i] = 0;
}
// Frame timer keeps running to animate the settle + show PAUSED state
@@ -498,8 +626,8 @@ export class UI {
if (!this.targetTimer) {
this.targetTimer = setInterval(() => {
if (!this.animating || this.vizPaused) return;
- for (let i = 0; i < BARS_COUNT; i++) {
- const center = BARS_COUNT / 2;
+ for (let i = 0; i < this.vizWidth; i++) {
+ const center = this.vizWidth / 2;
const dist = Math.abs(i - center) / center;
const maxH = BAR_HEIGHT * (1 - dist * 0.35);
const burst = Math.random() > 0.85 ? 1.3 : 1.0;
@@ -529,7 +657,7 @@ export class UI {
for (let row = 0; row < BAR_HEIGHT; row++) {
const threshold = BAR_HEIGHT - row; // what bar height fills this row
let line = ' ';
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
const h = this.bars[i];
if (h >= threshold) {
// Full block in this row
@@ -550,11 +678,11 @@ export class UI {
// ── Center line ──
let center = ' ';
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
const h = this.bars[i];
if (h > 0.1) {
// Use a rainbow color at the center for flair
- const colorIdx = Math.floor((i / BARS_COUNT) * theme.headerGradient.length);
+ const colorIdx = Math.floor((i / this.vizWidth) * theme.headerGradient.length);
center += chalk.hex(theme.headerGradient[colorIdx % theme.headerGradient.length])('\u2500');
} else {
center += chalk.hex(theme.muted)('\u2504');
@@ -566,7 +694,7 @@ export class UI {
for (let row = 0; row < MIRROR_HEIGHT; row++) {
const threshold = row + 1; // mirror row 0 = just below center
let line = ' ';
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
// Mirror height is scaled down
const mirrorH = this.bars[i] * (MIRROR_HEIGHT / BAR_HEIGHT) * 0.6;
if (mirrorH >= threshold) {
@@ -602,7 +730,7 @@ export class UI {
const colored = chalk.hex(theme.muted)(pauseText);
const result = [...lines];
if (midRow >= 0 && midRow < result.length) {
- const padded = ' '.repeat(Math.max(0, Math.floor((BARS_COUNT - pauseText.length) / 2) + 2));
+ const padded = ' '.repeat(Math.max(0, Math.floor((this.vizWidth - pauseText.length) / 2) + 2));
result[midRow] = padded + colored;
}
return result;
@@ -614,10 +742,10 @@ export class UI {
const theme = this.theme;
const waveGradient = theme.wave.map((color) => chalk.hex(color));
// Build a grid
- const grid = Array.from({ length: WAVE_HEIGHT }, () => new Array(BARS_COUNT).fill(0));
+ const grid = Array.from({ length: WAVE_HEIGHT }, () => new Array(this.vizWidth).fill(0));
// Calculate wave positions
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
// Oscilloscope: sine wave modulated by bar amplitude
const amp = this.bars[i] / BAR_HEIGHT;
const wave = Math.sin(i * 0.2 + this.tapeFrame * 0.06) * amp * WAVE_CENTER;
@@ -638,7 +766,7 @@ export class UI {
let line = ' ';
if (row === WAVE_CENTER) {
// Center baseline
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
const v = grid[row][i];
if (v >= 4) {
line += waveGradient[0](FULL_BLOCK);
@@ -651,7 +779,7 @@ export class UI {
}
}
} else {
- for (let i = 0; i < BARS_COUNT; i++) {
+ for (let i = 0; i < this.vizWidth; i++) {
const v = grid[row][i];
if (v >= 4) {
line += waveGradient[0](FULL_BLOCK);
@@ -680,12 +808,12 @@ export class UI {
if (this.tapeFrame % 3 === 0) {
// Shift everything down
for (let row = RAIN_HEIGHT - 1; row > 0; row--) {
- for (let col = 0; col < BARS_COUNT; col++) {
+ for (let col = 0; col < this.vizWidth; col++) {
this.rainGrid[row][col] = this.rainGrid[row - 1][col];
}
}
// Spawn new drops at top based on bar intensity
- for (let col = 0; col < BARS_COUNT; col++) {
+ for (let col = 0; col < this.vizWidth; col++) {
const h = this.bars[col];
if (h > 0.8 && Math.random() < (h / BAR_HEIGHT) * 0.7) {
this.rainGrid[0][col] = Math.min(4, Math.ceil(h / 2));
@@ -699,7 +827,7 @@ export class UI {
const lines = [];
for (let row = 0; row < RAIN_HEIGHT; row++) {
let line = ' ';
- for (let col = 0; col < BARS_COUNT; col++) {
+ for (let col = 0; col < this.vizWidth; col++) {
const v = this.rainGrid[row][col];
if (v > 0 && v <= 4) {
// Fade intensity based on row (further down = dimmer)
@@ -723,17 +851,17 @@ export class UI {
const flameGradient = theme.flame.map((color) => chalk.hex(color));
// Update flame heat map
// Bottom row = bar values (heat source)
- for (let col = 0; col < BARS_COUNT; col++) {
+ for (let col = 0; col < this.vizWidth; col++) {
this.flameGrid[FLAME_HEIGHT - 1][col] = this.bars[col] / BAR_HEIGHT;
}
// Propagate heat upward with decay, spread, and randomness
for (let row = 0; row < FLAME_HEIGHT - 1; row++) {
- for (let col = 0; col < BARS_COUNT; col++) {
+ for (let col = 0; col < this.vizWidth; col++) {
// Heat comes from below: average of below-left, below, below-right
const below = this.flameGrid[row + 1][col];
const belowL = col > 0 ? this.flameGrid[row + 1][col - 1] : 0;
- const belowR = col < BARS_COUNT - 1 ? this.flameGrid[row + 1][col + 1] : 0;
+ const belowR = col < this.vizWidth - 1 ? this.flameGrid[row + 1][col + 1] : 0;
// Weighted average with decay
let heat = (below * 0.55 + belowL * 0.15 + belowR * 0.15);
// Add flicker randomness
@@ -749,7 +877,7 @@ export class UI {
const lines = [];
for (let row = 0; row < FLAME_HEIGHT; row++) {
let line = ' ';
- for (let col = 0; col < BARS_COUNT; col++) {
+ for (let col = 0; col < this.vizWidth; col++) {
const heat = this.flameGrid[row][col];
if (heat < 0.05) {
line += ' ';
@@ -797,7 +925,7 @@ export class UI {
chalk.hex(tape.frame)(' \u2502') +
chalk.hex(tape.badgeLeft)(' \u266b ') +
chalk.hex(tape.label).bold('SONIC PLAYER') +
- ' '.repeat(deckWidth - 16) +
+ ' '.repeat(deckWidth - 18) +
chalk.hex(tape.badgeRight)(' \u266b') +
chalk.hex(tape.frame)(' \u2502')
);
@@ -892,6 +1020,16 @@ export class UI {
return lines.join('\n');
}
+ // Crop a visualizer to `maxRows`, trimming the reflection side first.
+ fitRows(block, maxRows) {
+ const lines = block.split('\n');
+ if (lines.length <= maxRows) return block;
+ if (maxRows < 3) return '';
+ const drop = lines.length - maxRows;
+ const fromTop = Math.floor(drop / 3);
+ return lines.slice(fromTop, fromTop + maxRows).join('\n');
+ }
+
// ─── Cleanup ─────────────────────────────────────────────────────────────
cleanup() {
@@ -904,8 +1042,52 @@ export class UI {
// ─── Helpers ─────────────────────────────────────────────────────────────────
function truncate(str, max) {
- if (!str) return '';
- return str.length > max ? str.slice(0, max - 1) + '\u2026' : str;
+ return fit(str, max).trimEnd();
+}
+
+const ANSI = /\x1b\[[0-9;?]*[a-zA-Z]/g;
+const ZERO_WIDTH = /[\u0300-\u036F\u200B-\u200F\u20D0-\u20FF\uFE00-\uFE0F]|\p{M}/u;
+const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|\p{Emoji_Presentation}/u;
+
+function charWidth(ch) {
+ if (ZERO_WIDTH.test(ch)) return 0;
+ return WIDE.test(ch) ? 2 : 1;
+}
+
+// Terminal columns a string takes, so emoji and CJK titles line up.
+export function displayWidth(str) {
+ let width = 0;
+ for (const ch of String(str || '').replace(ANSI, '')) width += charWidth(ch);
+ return width;
+}
+
+// Truncate to `width` columns with an ellipsis, then pad to exactly `width`.
+function fit(str, width) {
+ const text = String(str || '').replace(/\s+/g, ' ').trim();
+ if (displayWidth(text) <= width) return text + ' '.repeat(width - displayWidth(text));
+ let out = '';
+ let used = 0;
+ for (const ch of text) {
+ const w = charWidth(ch);
+ if (used + w > width - 1) break;
+ out += ch;
+ used += w;
+ }
+ return out + '\u2026' + ' '.repeat(Math.max(0, width - used - 1));
+}
+
+// Move to an absolute 1-based column, so later fields line up even when a
+// terminal draws an emoji narrower or wider than expected.
+function col(n) {
+ return `\x1b[${n}G`;
+}
+
+function trackLength(track) {
+ if (track.live) return 'LIVE';
+ if (track.kind && track.kind !== 'track' && track.kind !== 'episode') {
+ return track.trackCount ? `${track.trackCount} trk` : track.kind;
+ }
+ return formatDuration(track.duration);
}
function padReelVertical(reel, targetHeight) {
diff --git a/package-lock.json b/package-lock.json
index 07894ce..6409821 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "sonic-cli",
+ "name": "sonic-tui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "sonic-cli",
+ "name": "sonic-tui",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index f327f09..4af268e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "sonic-cli",
+ "name": "sonic-tui",
"version": "0.1.0",
- "description": "A terminal music player with YouTube, SoundCloud, and Mixcloud — powered by mpv and yt-dlp",
+ "description": "A vibes-first terminal music player for YouTube, SoundCloud, Mixcloud, and Spotify",
"license": "MIT",
"type": "module",
"author": "Seth Mills",
@@ -18,7 +18,7 @@
],
"scripts": {
"start": "node ./bin/sonic.js",
- "check": "node --check bin/sonic.js && node --check lib/search.js && node --check lib/player.js && node --check lib/channels.js && node --check lib/ui.js && node --check lib/system.js && node --check lib/store.js && node --check lib/themes.js",
+ "check": "node --check bin/sonic.js && node --check lib/search.js && node --check lib/player.js && node --check lib/spotify.js && node --check lib/channels.js && node --check lib/ui.js && node --check lib/system.js && node --check lib/store.js && node --check lib/themes.js",
"help": "node ./bin/sonic.js --help",
"doctor": "node ./bin/sonic.js doctor"
},
@@ -42,6 +42,7 @@
"youtube",
"soundcloud",
"mixcloud",
+ "spotify",
"mpv",
"yt-dlp",
"visualizer"