@ciphore/radiocli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.7] - 2026-06-29
11
+
12
+ ### Changed
13
+
14
+ - Search results can now be navigated with `Up` / `Down` while editing the
15
+ search query; search-history recall remains available with
16
+ `Ctrl+Up` / `Ctrl+Down`.
17
+ - The listening stats activity heatmap now renders day cells as square markers
18
+ and uses a capped contribution scale so one unusually long listening day does
19
+ not flatten the rest of the Less/More color range.
20
+
21
+ ## [0.1.6] - 2026-06-17
22
+
23
+ ### Added
24
+
25
+ - Added VLC (`cvlc`/`vlc`) as a third playback-only backend so installs without
26
+ `mpv` or `ffplay` still play audio.
27
+ - Added a Help screen (`?`) listing every keybinding and `:` command, plus
28
+ command-name tab completion in the command palette.
29
+ - Added persisted ICY track history shown on Now Playing (`d`) and up/down
30
+ recall of recent search queries in the search box.
31
+ - Added opt-in resume of the most recent station on launch.
32
+ - Added `O` to open the station homepage and `y` to copy its stream URL.
33
+ - Added ASCII-safe display, transparent background (also honoring `NO_COLOR`),
34
+ and reduce-motion settings for terminal and low-power compatibility.
35
+ - Added a best-effort Radio Browser upvote when favoriting a station.
36
+ - Added a `radiocli version` command (and `--version` / `-v`).
37
+ - Added `:doctor`, `:help`, `:resume`, `:ascii`, `:motion`, and `:background`
38
+ command-palette entries.
39
+
40
+ ### Changed
41
+
42
+ - Playback binary detection now probes common install locations (Homebrew,
43
+ Scoop, WinGet, Chocolatey, and the VLC app bundle) when a tool is not on
44
+ `PATH`, so GUI-launched terminals still find `mpv`/`ffplay`/VLC.
45
+ - The Radio Browser `User-Agent` now reports the installed package version.
46
+
10
47
  ## [0.1.5] - 2026-05-31
11
48
 
12
49
  ### Fixed
@@ -119,6 +156,8 @@ Initial public release.
119
156
  rows go to the visualizer.
120
157
 
121
158
  [0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
159
+ [0.1.6]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.6
160
+ [0.1.7]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.7
122
161
  [0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
123
162
  [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
124
163
  [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
package/README.md CHANGED
@@ -13,8 +13,10 @@ It is built with [Ink](https://github.com/vadimdemedes/ink), [React](https://rea
13
13
  ## Features
14
14
 
15
15
  - Explore public radio from around the world through a cosmo-style braille world map beside the station list, with click-to-place mouse support and WASD keyboard movement backed by a cached geotagged station atlas. Country lists, global station search, a country-density map, and opt-in nearby discovery round out the discovery surface.
16
- - Tune stations with `mpv` first for full playback controls, with `ffplay` as a playback-only fallback when available.
17
- - Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, `mpv`-backed volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
16
+ - Tune stations with `mpv` first for full playback controls, with `ffplay` and VLC (`cvlc`/`vlc`) as playback-only fallbacks. RadioCLI also probes common install locations (Homebrew, Scoop, WinGet, Chocolatey, VLC app bundle) when a backend is not on `PATH`, so GUI-launched terminals still find it.
17
+ - Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata and persisted track history, stream diagnostics, sleep timer, favorite state, `mpv`-backed volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
18
+ - Open the in-app Help screen (`?`) for every keybinding and `:` command, with command-name tab completion and search-history recall. Open a station homepage (`O`) or copy its stream URL (`y`).
19
+ - Adapt to any terminal: ASCII-safe display, transparent background (and `NO_COLOR`) for light terminals, reduce-motion for SSH and low-power devices, and opt-in resume of the last station on launch.
18
20
  - Keep shortcuts in a fixed adaptive footer: a compact live station and track row appears above page-specific and global controls while playback is active.
19
21
  - Move previous/next through the exact station list you tuned from, even after navigating to another screen.
20
22
  - Browse dense station lists with inline location/codec metadata and yellow favorite stars next to station names.
@@ -75,7 +77,7 @@ Live public radio from around the world
75
77
  3 recent · 2 favorites · 1 imported
76
78
 
77
79
  ↑/↓ move · Enter open · number jump · : command
78
- ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
80
+ ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
79
81
  ```
80
82
 
81
83
  The Now Playing screen is a framed receiver panel with **50 selectable receiver styles**. The sample below shows the default pulse-grid display; press `v` to cycle through the catalog, which spans several families:
@@ -106,7 +108,7 @@ Now playing ──────────────────────
106
108
 
107
109
  Playing: KEXP 90.3 FM · Now: Artist - Track · Seattle, Washington, United States · MP3 / 128 kbps / english · mpv · playing · vol 70 · Nearby 4/90
108
110
  space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b home
109
- ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
111
+ ←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit
110
112
  ```
111
113
 
112
114
  For the exact non-interactive demo transcript:
@@ -277,7 +279,7 @@ Page-specific footer controls:
277
279
  | Screen | Controls |
278
280
  | --- | --- |
279
281
  | Home | `↑` / `↓` move, `Enter` open, number jump, `:` command |
280
- | Search input | type query, `Backspace` edit, `Enter` search or tune, `Esc` finish |
282
+ | Search input | type query, `Backspace` edit, `Up` / `Down` move results, `Ctrl+Up` / `Ctrl+Down` recall search history, `Enter` search or tune, `Esc` finish |
281
283
  | Search results | `/` edit query, `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home |
282
284
  | Explore | click map, `WASD` fine move, `Shift+WASD` jump, `↑` / `↓` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
283
285
  | Countries | `/` filter, `↑` / `↓` move, `Enter` open stations, `w` map, `b` home |
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { PlayerController } from './player/player-controller.js';
8
8
  import { JsonLibraryStore } from './storage/store.js';
9
9
  import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
10
10
  import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
11
+ import { appVersion } from './version.js';
11
12
  if (isDirectRun(process.argv[1], import.meta.url)) {
12
13
  const args = process.argv.slice(2);
13
14
  if (args.length > 0) {
@@ -33,6 +34,10 @@ export async function runCommand(args) {
33
34
  printHelp();
34
35
  return;
35
36
  }
37
+ if (command === 'version' || command === '--version' || command === '-v') {
38
+ console.log(appVersion());
39
+ return;
40
+ }
36
41
  if (!isKnownCommand(command)) {
37
42
  throw new Error(`Unknown command: ${command}\nRun radiocli help.`);
38
43
  }
@@ -113,6 +118,7 @@ export function printHelp() {
113
118
 
114
119
  Usage:
115
120
  radiocli Start the TUI
121
+ radiocli version Print the installed version
116
122
  radiocli check Show provider/backend health
117
123
  radiocli doctor Show local playback setup guidance
118
124
  radiocli countries Print top countries
@@ -2,8 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { commandExists } from './command.js';
3
3
  import { airPlaySenderHealth } from './airplay-sender-health.js';
4
4
  export const ffplayLimitedControlsMessage = 'ffplay fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
5
+ export const vlcLimitedControlsMessage = 'VLC fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
5
6
  export function detectPlaybackBackends({ platform = process.platform, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
6
7
  const backends = ['mpv', 'ffplay'].filter(hasCommand);
8
+ if (hasCommand('cvlc') || hasCommand('vlc')) {
9
+ backends.push('vlc');
10
+ }
7
11
  if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) {
8
12
  backends.push('airplay');
9
13
  }
@@ -33,6 +37,16 @@ export function playbackBackendCapabilities(backend) {
33
37
  supportsMediaKeys: false
34
38
  };
35
39
  }
40
+ if (backend === 'vlc') {
41
+ return {
42
+ backend,
43
+ label: 'VLC fallback',
44
+ supportsPause: false,
45
+ supportsMute: false,
46
+ supportsVolume: false,
47
+ supportsMediaKeys: false
48
+ };
49
+ }
36
50
  if (backend === 'airplay') {
37
51
  return {
38
52
  backend,
@@ -82,6 +96,15 @@ export function playbackBackendStatusLines(backends, platform = process.platform
82
96
  ...lines
83
97
  ];
84
98
  }
99
+ if (backendSet.has('vlc')) {
100
+ return [
101
+ 'playback=fallback-only',
102
+ 'playback_backend=vlc',
103
+ 'controls=limited',
104
+ 'controls_hint=install mpv for pause, mute, volume, and media keys',
105
+ ...lines
106
+ ];
107
+ }
85
108
  if (backendSet.has('airplay')) {
86
109
  return [
87
110
  'playback=airplay-only',
@@ -1,8 +1,96 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  export function commandExists(command) {
6
+ return resolveCommand(command) !== null;
7
+ }
8
+ // Resolve a runnable path for a command. GUI-launched terminals (macOS .app
9
+ // terminals, some Linux desktop launchers) frequently do not inherit the login
10
+ // shell PATH, so a perfectly installed binary in /opt/homebrew/bin or a Windows
11
+ // package-manager shim looks "missing" to a bare PATH lookup. We therefore fall
12
+ // back to probing well-known install locations before giving up.
13
+ export function resolveCommand(command) {
14
+ return lookupOnPath(command) ?? probeKnownLocations(command);
15
+ }
16
+ function lookupOnPath(command) {
3
17
  const lookup = process.platform === 'win32' ? 'where' : 'which';
4
- const result = spawnSync(lookup, [command], {
5
- stdio: 'ignore'
6
- });
7
- return result.status === 0;
18
+ const result = spawnSync(lookup, [command], { encoding: 'utf8' });
19
+ if (result.status !== 0 || typeof result.stdout !== 'string') {
20
+ return null;
21
+ }
22
+ const first = result.stdout
23
+ .split(/\r?\n/)
24
+ .map(line => line.trim())
25
+ .find(Boolean);
26
+ return first ?? null;
27
+ }
28
+ function probeKnownLocations(command) {
29
+ for (const candidate of candidatePaths(command)) {
30
+ if (existsSync(candidate)) {
31
+ return candidate;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ function candidatePaths(command) {
37
+ const paths = [];
38
+ const names = candidateNames(command);
39
+ for (const dir of knownBinaryDirs()) {
40
+ for (const name of names) {
41
+ paths.push(join(dir, name));
42
+ }
43
+ }
44
+ paths.push(...appBundlePaths(command));
45
+ return paths;
46
+ }
47
+ function candidateNames(command) {
48
+ if (process.platform === 'win32' && !/\.[a-z0-9]+$/i.test(command)) {
49
+ return [`${command}.exe`, `${command}.com`, `${command}.bat`, `${command}.cmd`, command];
50
+ }
51
+ return [command];
52
+ }
53
+ function knownBinaryDirs() {
54
+ const home = homedir();
55
+ if (process.platform === 'darwin') {
56
+ return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', join(home, '.local', 'bin')];
57
+ }
58
+ if (process.platform === 'win32') {
59
+ const dirs = [];
60
+ if (process.env.LOCALAPPDATA) {
61
+ dirs.push(join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links'));
62
+ }
63
+ dirs.push(join(home, 'scoop', 'shims'));
64
+ if (process.env.ProgramData) {
65
+ dirs.push(join(process.env.ProgramData, 'chocolatey', 'bin'));
66
+ }
67
+ return dirs;
68
+ }
69
+ return [
70
+ '/usr/bin',
71
+ '/usr/local/bin',
72
+ '/bin',
73
+ '/usr/local/sbin',
74
+ '/snap/bin',
75
+ '/var/lib/flatpak/exports/bin',
76
+ join(home, '.local', 'bin')
77
+ ];
78
+ }
79
+ // Some media players (notably VLC) commonly install without putting a CLI entry
80
+ // on PATH, so probe their standard application directories directly.
81
+ function appBundlePaths(command) {
82
+ if (command !== 'vlc' && command !== 'cvlc') {
83
+ return [];
84
+ }
85
+ if (process.platform === 'darwin') {
86
+ return [
87
+ '/Applications/VLC.app/Contents/MacOS/VLC',
88
+ join(homedir(), 'Applications', 'VLC.app', 'Contents', 'MacOS', 'VLC')
89
+ ];
90
+ }
91
+ if (process.platform === 'win32') {
92
+ const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean);
93
+ return roots.map(root => join(root, 'VideoLAN', 'VLC', 'vlc.exe'));
94
+ }
95
+ return [];
8
96
  }
@@ -4,7 +4,8 @@ import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { Socket } from 'node:net';
7
- import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint } from './backend-install.js';
7
+ import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
8
+ import { resolveCommand } from './command.js';
8
9
  import { discoverAirPlayDevices } from './airplay-discovery.js';
9
10
  import { airPlaySenderHealth } from './airplay-sender-health.js';
10
11
  import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
@@ -167,6 +168,10 @@ export class PlayerController {
167
168
  this.playWithFfplay(url);
168
169
  await this.waitForReady(backend);
169
170
  }
171
+ else if (backend === 'vlc') {
172
+ this.playWithVlc(url);
173
+ await this.waitForReady(backend);
174
+ }
170
175
  this.setState({
171
176
  backend,
172
177
  state: 'playing',
@@ -300,6 +305,9 @@ export class PlayerController {
300
305
  if (preferred === 'ffplay') {
301
306
  return backends.includes('ffplay') ? 'ffplay' : null;
302
307
  }
308
+ if (preferred === 'vlc') {
309
+ return backends.includes('vlc') ? 'vlc' : null;
310
+ }
303
311
  if (preferred === 'airplay') {
304
312
  return backends.includes('airplay') ? 'airplay' : null;
305
313
  }
@@ -309,6 +317,9 @@ export class PlayerController {
309
317
  if (backends.includes('ffplay')) {
310
318
  return 'ffplay';
311
319
  }
320
+ if (backends.includes('vlc')) {
321
+ return 'vlc';
322
+ }
312
323
  return null;
313
324
  }
314
325
  playbackUnavailableMessage() {
@@ -324,7 +335,7 @@ export class PlayerController {
324
335
  playWithMpv(url, initialTitle) {
325
336
  this.ipcPath = createMpvIpcPath();
326
337
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
327
- this.process = spawn('mpv', [
338
+ this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
328
339
  '--no-video',
329
340
  '--really-quiet',
330
341
  '--force-window=no',
@@ -338,11 +349,19 @@ export class PlayerController {
338
349
  this.wireProcess();
339
350
  }
340
351
  playWithFfplay(url) {
341
- this.process = spawn('ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
352
+ this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
342
353
  stdio: ['pipe', 'pipe', 'pipe']
343
354
  });
344
355
  this.wireProcess();
345
356
  }
357
+ playWithVlc(url) {
358
+ // VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
359
+ // binary (resolved from app bundles when off PATH) with a dummy interface.
360
+ const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
361
+ const gain = (clampVolume(this.getSettings().volume) / 100).toFixed(2);
362
+ this.process = spawn(binary, ['--intf', 'dummy', '--no-video', '--quiet', '--play-and-exit', `--gain=${gain}`, url], { stdio: ['pipe', 'pipe', 'pipe'] });
363
+ this.wireProcess();
364
+ }
346
365
  canRetuneAirPlay(device) {
347
366
  return Boolean(this.backend === 'airplay' &&
348
367
  this.process &&
@@ -555,6 +574,9 @@ export class PlayerController {
555
574
  if (this.backend === 'ffplay' && this.process) {
556
575
  return { ok: false, message: ffplayLimitedControlsMessage };
557
576
  }
577
+ if (this.backend === 'vlc' && this.process) {
578
+ return { ok: false, message: vlcLimitedControlsMessage };
579
+ }
558
580
  return null;
559
581
  }
560
582
  wireProcess() {
@@ -708,7 +730,7 @@ export class PlayerController {
708
730
  if (!this.process) {
709
731
  throw new Error('Player exited before the stream became ready.');
710
732
  }
711
- if (backend === 'ffplay') {
733
+ if (backend === 'ffplay' || backend === 'vlc') {
712
734
  await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs));
713
735
  return;
714
736
  }
@@ -37,6 +37,9 @@ export class ProviderManager {
37
37
  }
38
38
  return this.radioBrowser.resolve(station);
39
39
  }
40
+ vote(station) {
41
+ return this.radioBrowser.vote(station);
42
+ }
40
43
  async health(settings) {
41
44
  const radioBrowser = await this.radioBrowser.health().catch(error => error instanceof Error ? error.message : 'unavailable');
42
45
  const radioGarden = settings.enableRadioGarden
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ProviderCache } from './cache.js';
3
+ import { userAgent } from '../version.js';
3
4
  const stationSchema = z.object({
4
5
  stationuuid: z.string(),
5
6
  name: z.string().default('Untitled station'),
@@ -164,13 +165,28 @@ export class RadioBrowserProvider {
164
165
  }
165
166
  return { url: result.url, name: result.name };
166
167
  }
168
+ // Give back to the directory: an upvote on favorite helps surface good
169
+ // stations for everyone. Radio Browser rate-limits votes per IP, so this is
170
+ // best-effort and never blocks the favorite action.
171
+ async vote(station) {
172
+ if (station.provider !== this.id) {
173
+ return false;
174
+ }
175
+ try {
176
+ const result = clickResolveSchema.parse(await this.request(`/json/vote/${encodeURIComponent(station.id)}`, {}, { timeoutMs: 5000 }));
177
+ return result.ok;
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
167
183
  async detectLocation() {
168
184
  const controller = new AbortController();
169
185
  const timeout = setTimeout(() => controller.abort(), 5000);
170
186
  try {
171
187
  const response = await fetch('https://ipapi.co/json/', {
172
188
  signal: controller.signal,
173
- headers: { 'User-Agent': 'radiocli/0.1' }
189
+ headers: { 'User-Agent': userAgent() }
174
190
  });
175
191
  if (!response.ok) {
176
192
  return null;
@@ -306,7 +322,7 @@ async function fetchWithTimeout(url, timeoutMs) {
306
322
  return await fetch(url, {
307
323
  signal: controller.signal,
308
324
  headers: {
309
- 'User-Agent': 'radiocli/0.1 (+https://radio-browser.info)',
325
+ 'User-Agent': userAgent(' (+https://radio-browser.info)'),
310
326
  Accept: 'application/json'
311
327
  }
312
328
  });
@@ -136,7 +136,7 @@ const settingsSchema = z.object({
136
136
  volume: z.number().min(0).max(100).default(70),
137
137
  enableRadioGarden: z.boolean().default(false),
138
138
  enableNearbyLocation: z.boolean().default(false),
139
- preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'airplay']).default('auto'),
139
+ preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
140
140
  preferredAirPlayDevice: z.string().min(1).optional(),
141
141
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
142
142
  skipBrokenStreams: z.boolean().default(true),
@@ -146,7 +146,11 @@ const settingsSchema = z.object({
146
146
  playPause: z.array(z.string()).default([]),
147
147
  next: z.array(z.string()).default([])
148
148
  })
149
- .default(defaultMediaKeys)
149
+ .default(defaultMediaKeys),
150
+ resumeOnLaunch: z.boolean().default(false),
151
+ transparentBackground: z.boolean().default(false),
152
+ asciiMode: z.boolean().default(false),
153
+ reduceMotion: z.boolean().default(false)
150
154
  });
151
155
  const librarySchema = z.object({
152
156
  recent: z
@@ -157,6 +161,15 @@ const librarySchema = z.object({
157
161
  .default([]),
158
162
  favorites: z.array(stationSchema).default([]),
159
163
  imported: z.array(stationSchema).default([]),
164
+ trackHistory: z
165
+ .array(z.object({
166
+ title: z.string(),
167
+ stationKey: z.string(),
168
+ stationName: z.string(),
169
+ at: z.string()
170
+ }))
171
+ .default([]),
172
+ searchHistory: z.array(z.string()).default([]),
160
173
  activity: z
161
174
  .object({
162
175
  sessions: z
@@ -215,6 +228,39 @@ export class JsonLibraryStore {
215
228
  this.write();
216
229
  return this.snapshot();
217
230
  }
231
+ // Persist the ICY track titles a station announces so "what was that song?"
232
+ // is answerable later. Consecutive duplicates on the same station are skipped.
233
+ recordTrack(station, title) {
234
+ const cleaned = title.trim();
235
+ if (!cleaned) {
236
+ return this.snapshot();
237
+ }
238
+ const key = stationKey(station);
239
+ const last = this.state.trackHistory[0];
240
+ if (last && last.title === cleaned && last.stationKey === key) {
241
+ return this.snapshot();
242
+ }
243
+ const entry = {
244
+ title: cleaned,
245
+ stationKey: key,
246
+ stationName: station.name,
247
+ at: new Date().toISOString()
248
+ };
249
+ this.state = { ...this.state, trackHistory: [entry, ...this.state.trackHistory].slice(0, 100) };
250
+ this.write();
251
+ return this.snapshot();
252
+ }
253
+ // Most-recent-first search queries for up-arrow recall in the search box.
254
+ addSearch(query) {
255
+ const cleaned = query.trim();
256
+ if (!cleaned) {
257
+ return this.snapshot();
258
+ }
259
+ const searchHistory = [cleaned, ...this.state.searchHistory.filter(item => item !== cleaned)].slice(0, 30);
260
+ this.state = { ...this.state, searchHistory };
261
+ this.write();
262
+ return this.snapshot();
263
+ }
218
264
  startListeningSession(station, startedAt = new Date()) {
219
265
  this.finishActiveListeningSession(startedAt);
220
266
  const session = {
@@ -329,6 +375,8 @@ function defaultState() {
329
375
  recent: [],
330
376
  favorites: [],
331
377
  imported: [],
378
+ trackHistory: [],
379
+ searchHistory: [],
332
380
  activity: { sessions: [] },
333
381
  settings: {
334
382
  theme: 'green',