@ciphore/radiocli 0.2.3 → 0.2.4

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ /** Environment overrides affect this invocation, never the saved settings. */
2
+ export function resolveTerminalCapabilities(env = process.env, evidence = {}) {
3
+ const dumb = env.TERM?.toLowerCase() === 'dumb';
4
+ const screenReader = evidence.screenReader === true || env.INK_SCREEN_READER === 'true' || flag(env.RADIOCLI_SCREEN_READER) === true;
5
+ const interactive = evidence.isTTY !== false && !dumb;
6
+ const asciiOverride = flag(env.RADIOCLI_ASCII);
7
+ const unicodeOverride = flag(env.RADIOCLI_UNICODE);
8
+ const locale = [env.LC_ALL, env.LC_CTYPE, env.LANG].find(value => value?.trim())?.trim();
9
+ // An explicit ASCII request wins when both overrides are enabled. A bare
10
+ // C/POSIX locale is a known constraint; SSH and missing locale data are not.
11
+ const unicode = dumb ? false
12
+ : asciiOverride === true ? false
13
+ : unicodeOverride === true ? true
14
+ : asciiOverride === false ? true
15
+ : unicodeOverride === false ? false
16
+ : locale === 'C' || locale === 'POSIX' ? false : !evidence.asciiMode;
17
+ const colorLevel = dumb || screenReader || Boolean(env.NO_COLOR) ? 0 : resolveColorLevel(env, evidence);
18
+ return {
19
+ unicode,
20
+ colorLevel,
21
+ screenReader,
22
+ reduceMotion: Boolean(evidence.reduceMotion) || !interactive || screenReader || env.RADIOCLI_DISABLE_ANIMATION === '1' || env.RADIO_ATLAS_DISABLE_ANIMATION === '1',
23
+ interactive
24
+ };
25
+ }
26
+ function resolveColorLevel(env, evidence) {
27
+ const forced = env.FORCE_COLOR;
28
+ if (forced === '0' || forced === 'false')
29
+ return 0;
30
+ if (forced === '2')
31
+ return 2;
32
+ if (forced === '3')
33
+ return 3;
34
+ if (forced === '' || forced === '1' || forced === 'true')
35
+ return 1;
36
+ if (evidence.isTTY === false)
37
+ return 0;
38
+ // COLORTERM=truecolor/24bit and direct-color TERM values are explicit
39
+ // capability declarations. Some terminals expose those declarations while
40
+ // Node's getColorDepth() still reports 8 bits, so honor them before the
41
+ // conservative stream probe to avoid silently quantizing RGB themes.
42
+ if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? '') || /(?:direct|truecolor)/i.test(env.TERM ?? ''))
43
+ return 3;
44
+ if (evidence.colorDepth !== undefined) {
45
+ return evidence.colorDepth <= 1 ? 0 : evidence.colorDepth <= 4 ? 1 : evidence.colorDepth <= 8 ? 2 : 3;
46
+ }
47
+ if (/256color/i.test(env.TERM ?? ''))
48
+ return 2;
49
+ if (env.TERM)
50
+ return 1;
51
+ // Preserve the existing rich display unless the environment supplies a
52
+ // concrete constraint. Native callers can pass stdout.getColorDepth().
53
+ return 3;
54
+ }
55
+ function flag(value) {
56
+ if (value === undefined)
57
+ return undefined;
58
+ if (/^(1|true|yes|on)$/i.test(value))
59
+ return true;
60
+ if (/^(0|false|no|off)$/i.test(value))
61
+ return false;
62
+ return undefined;
63
+ }
@@ -0,0 +1,203 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { posix, win32 } from 'node:path';
3
+ import { resolveCommandDetails } from './executables.js';
4
+ import { identifyPlatform, nativeAdapters } from './runtime.js';
5
+ import { powershellCommand } from './shell.js';
6
+ import { launchEnvironment, nodeLaunchCommand, waitForLaunch } from './launch-command.js';
7
+ const unixTerminals = [
8
+ { name: 'ghostty', args: ['-e'] }, { name: 'wezterm', args: ['start', '--always-new-process', '--'] },
9
+ { name: 'kitty', args: ['-e'] }, { name: 'gnome-terminal', args: ['--'] }, { name: 'konsole', args: ['-e'] },
10
+ { name: 'xfce4-terminal', args: ['-x'] }, { name: 'x-terminal-emulator', args: ['-e'], shell: true },
11
+ { name: 'alacritty', args: ['-e'] }, { name: 'foot', args: ['--'], display: 'wayland' },
12
+ { name: 'mate-terminal', args: ['-x'] }, { name: 'qterminal', args: ['-e'], shell: true },
13
+ { name: 'terminator', args: ['-x'] }, { name: 'tilix', args: ['-e'] },
14
+ { name: 'xterm', args: ['-e'], display: 'x11' }, { name: 'uxterm', args: ['-e'], display: 'x11' }
15
+ ];
16
+ const macTerminals = ['darwin:apple-terminal', 'darwin:iterm', 'darwin:wezterm', 'darwin:ghostty', 'darwin:kitty'];
17
+ export function detectGraphicalTerminal(platform = process.platform, env = process.env, resolve = resolver(platform, env)) {
18
+ const host = identifyPlatform({ platform, env });
19
+ const adapter = nativeAdapters(host).terminal;
20
+ const unsupported = `${host.id === 'unknown' ? platform : host.id}:unsupported`;
21
+ const configured = env.RADIOCLI_ALARM_TERMINAL?.trim();
22
+ if (adapter === 'macos') {
23
+ if (configured && macTerminals.includes(configured))
24
+ return configured;
25
+ const program = `${env.TERM_PROGRAM ?? ''} ${env.__CFBundleIdentifier ?? ''}`.toLowerCase();
26
+ for (const name of ['iterm', 'wezterm', 'ghostty', 'kitty'])
27
+ if (program.includes(name))
28
+ return `darwin:${name}`;
29
+ return 'darwin:apple-terminal';
30
+ }
31
+ if (adapter === 'windows') {
32
+ if (configured === 'win32:windows-terminal' || configured === 'win32:console')
33
+ return configured;
34
+ return env.WT_SESSION ? 'win32:windows-terminal' : 'win32:console';
35
+ }
36
+ if (adapter !== 'unix' || !hasDisplay(env))
37
+ return unsupported;
38
+ if (configured) {
39
+ const separator = configured.indexOf(':');
40
+ const prefix = separator < 0 ? '' : configured.slice(0, separator);
41
+ if (prefix === host.id || prefix === 'linux') {
42
+ const path = resolveUnixTerminal(configured.slice(separator + 1), env, resolve);
43
+ return path ? `${prefix}:${path}` : unsupported;
44
+ }
45
+ }
46
+ const requested = env.TERMINAL?.trim() || env.TERM_PROGRAM?.trim();
47
+ const selected = requested && resolveUnixTerminal(requested, env, resolve);
48
+ if (selected)
49
+ return `${host.id}:${selected}`;
50
+ for (const terminal of unixTerminals) {
51
+ const path = resolveUnixTerminal(terminal.name, env, resolve);
52
+ if (path)
53
+ return `${host.id}:${path}`;
54
+ }
55
+ return unsupported;
56
+ }
57
+ export function createTerminalLaunch(options) {
58
+ const platform = options.platform ?? process.platform;
59
+ const env = options.env ?? process.env;
60
+ const resolve = options.resolve ?? resolver(platform, env);
61
+ const terminal = detectGraphicalTerminal(platform, env, resolve);
62
+ const values = [options.nodePath, ...options.args];
63
+ if (values.some(value => value.includes('\0')))
64
+ throw new Error('Terminal command values cannot contain NUL bytes.');
65
+ const environment = launchEnvironment(env, { platform });
66
+ const direct = nodeLaunchCommand(options.nodePath, options.args, environment);
67
+ if (terminal.endsWith(':unsupported')) {
68
+ if (nativeAdapters(identifyPlatform({ platform, env })).terminal === 'unix' && !hasDisplay(env))
69
+ throw new Error('No graphical desktop session is available: DISPLAY and WAYLAND_DISPLAY are unset. Open radiocli manually for controls.');
70
+ throw new Error('No supported installed graphical terminal was found. Open radiocli manually for controls.');
71
+ }
72
+ if (terminal === 'darwin:apple-terminal' || terminal === 'darwin:iterm') {
73
+ const command = `${direct.map(shellQuote).join(' ')}${options.closeOnExit ? '; exit' : ''}`;
74
+ return { terminal, command: '/usr/bin/osascript', args: terminal === 'darwin:apple-terminal' ? appleTerminalScript(command) : iTermScript(command) };
75
+ }
76
+ if (terminal === 'darwin:wezterm')
77
+ return { terminal, command: '/usr/bin/open', args: ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct] };
78
+ if (terminal === 'darwin:ghostty')
79
+ return { terminal, command: '/usr/bin/open', args: ['-na', 'Ghostty', '--args', '-e', ...direct] };
80
+ if (terminal === 'darwin:kitty')
81
+ return { terminal, command: '/usr/bin/open', args: ['-na', 'kitty', '--args', '--detach', ...direct] };
82
+ if (terminal.startsWith('win32:')) {
83
+ const systemRoot = env.SystemRoot ?? env.WINDIR;
84
+ const powershell = resolve('powershell.exe') ?? (systemRoot ? resolve(win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) : undefined) ?? resolve('pwsh.exe');
85
+ if (!powershell)
86
+ throw new Error('PowerShell is unavailable; a RadioCLI terminal cannot be requested.');
87
+ // PowerShell 5 does not preserve arbitrary native argv quoting. Only a
88
+ // fixed quote-free Node program and encoded JSON cross that boundary.
89
+ const args = powershellCommand(direct, {}, { keepOpen: !options.closeOnExit });
90
+ if (terminal === 'win32:console')
91
+ return { terminal, command: powershell, ...newWindowsConsole(powershell, args) };
92
+ const wt = resolve('wt.exe') ?? (env.LOCALAPPDATA ? resolve(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'wt.exe')) : undefined);
93
+ if (!wt)
94
+ throw new Error('Windows Terminal is unavailable; the saved terminal cannot be requested.');
95
+ return { terminal, command: wt, args: ['-w', 'new', 'new-tab', '--title', options.title ?? 'RadioCLI', powershell, ...args] };
96
+ }
97
+ const executable = terminal.slice(terminal.indexOf(':') + 1);
98
+ const spec = unixTerminals.find(item => item.name === posix.basename(executable));
99
+ if (!spec)
100
+ throw new Error('The saved graphical terminal is unsupported.');
101
+ // QTerminal reparses only its first -e argument, then appends argv literally.
102
+ // The alternatives alias can select QTerminal too, so both use the fixed
103
+ // POSIX shell boundary. This also preserves argv for ordinary -e terminals.
104
+ if (spec.shell) {
105
+ const sh = resolve('/bin/sh');
106
+ if (!sh)
107
+ throw new Error('The POSIX shell required by this terminal is unavailable.');
108
+ return { terminal, command: executable, args: [...spec.args, sh, '-c', direct.map(shellQuote).join(' ')] };
109
+ }
110
+ return { terminal, command: executable, args: [...spec.args, ...direct] };
111
+ }
112
+ /** Request acceptance is separate from the caller's TUI/session verification. */
113
+ export async function launchTerminalCommand(options) {
114
+ const plan = createTerminalLaunch(options);
115
+ // PowerShell can exit without executing its script under DETACHED_PROCESS.
116
+ // Keep this short-lived bootstrap attached until it creates the independent
117
+ // console. The new console, not its bootstrap, owns the interactive handles.
118
+ // https://github.com/nodejs/node/issues/51018
119
+ const consoleBootstrap = plan.terminal === 'win32:console';
120
+ const child = (options.spawn ?? spawn)(plan.command, plan.args, { env: { ...(options.env ?? process.env), ...plan.environment }, detached: !consoleBootstrap, stdio: 'ignore', windowsHide: consoleBootstrap });
121
+ await waitForLaunch(child, { waitForExit: consoleBootstrap });
122
+ return plan.terminal;
123
+ }
124
+ function resolver(platform, env) { return command => resolveCommandDetails(command, { platform, env }).path ?? undefined; }
125
+ function hasDisplay(env) { return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim()); }
126
+ function resolveUnixTerminal(input, env, resolve) {
127
+ const spec = unixTerminals.find(item => item.name === posix.basename(input).toLowerCase());
128
+ if (!spec || spec.display === 'x11' && !env.DISPLAY?.trim() || spec.display === 'wayland' && !env.WAYLAND_DISPLAY?.trim())
129
+ return undefined;
130
+ const path = resolve(input);
131
+ return path && unixTerminals.some(item => item.name === posix.basename(path)) ? path : undefined;
132
+ }
133
+ function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; }
134
+ function newWindowsConsole(powershell, args) {
135
+ const key = 'RADIOCLI_WINDOWS_CONSOLE_COMMAND';
136
+ // The transient launcher has NUL stdio. CreateProcessW explicitly creates
137
+ // new console buffers without inheriting those handles; STARTF_USESTDHANDLES
138
+ // must stay unset. ShellExecute-based Start-Process does not provide this
139
+ // handle contract. Only fixed flags and encoded data enter the argument string.
140
+ // https://learn.microsoft.com/en-us/windows/console/creation-of-a-console
141
+ // https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw
142
+ const native = String.raw `
143
+ using System;
144
+ using System.ComponentModel;
145
+ using System.Runtime.InteropServices;
146
+ using System.Text;
147
+ public static class RadioCliConsole {
148
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
149
+ private struct StartupInfo {
150
+ public uint cb;
151
+ public string lpReserved, lpDesktop, lpTitle;
152
+ public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
153
+ public short wShowWindow, cbReserved2;
154
+ public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
155
+ }
156
+ [StructLayout(LayoutKind.Sequential)]
157
+ private struct ProcessInformation {
158
+ public IntPtr hProcess, hThread;
159
+ public uint dwProcessId, dwThreadId;
160
+ }
161
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
162
+ [return: MarshalAs(UnmanagedType.Bool)]
163
+ private static extern bool CreateProcessW(string application, StringBuilder commandLine,
164
+ IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
165
+ uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startup,
166
+ out ProcessInformation process);
167
+ [DllImport("kernel32.dll", SetLastError = true)]
168
+ [return: MarshalAs(UnmanagedType.Bool)]
169
+ private static extern bool CloseHandle(IntPtr handle);
170
+ public static void Launch(string application, string arguments) {
171
+ var commandLine = new StringBuilder("\"" + application + "\" " + arguments);
172
+ if (commandLine.Length >= 32767) throw new ArgumentException("The Windows console command exceeds the native command-line limit.");
173
+ var startup = new StartupInfo();
174
+ startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo));
175
+ startup.dwFlags = 0x00000001; // STARTF_USESHOWWINDOW only; no inherited standard handles.
176
+ startup.wShowWindow = 1; // SW_SHOWNORMAL
177
+ ProcessInformation process;
178
+ if (!CreateProcessW(application, commandLine, IntPtr.Zero, IntPtr.Zero, false,
179
+ 0x00000010, IntPtr.Zero, null, ref startup, out process)) { // CREATE_NEW_CONSOLE
180
+ throw new Win32Exception(Marshal.GetLastWin32Error());
181
+ }
182
+ CloseHandle(process.hThread);
183
+ CloseHandle(process.hProcess);
184
+ }
185
+ }`;
186
+ const script = [
187
+ "$ErrorActionPreference='Stop'",
188
+ "$ProgressPreference='SilentlyContinue'",
189
+ `$radiocliConsole=[Environment]::GetEnvironmentVariable('${key}','Process') | ConvertFrom-Json`,
190
+ `[Environment]::SetEnvironmentVariable('${key}',$null,'Process')`,
191
+ `Add-Type -TypeDefinition '${native.replaceAll("'", "''")}'`,
192
+ "[RadioCliConsole]::Launch([string]$radiocliConsole.command, [string]::Join(' ', [string[]]$radiocliConsole.args))"
193
+ ].join(';');
194
+ // Only this short-lived outer process needs the handoff. Encoding the inner
195
+ // PowerShell command again exceeds Windows' command-line limit for ordinary
196
+ // deep install paths. Clear the private variable before creating the console.
197
+ return {
198
+ args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')],
199
+ environment: { [key]: JSON.stringify({ command: powershell, args }) }
200
+ };
201
+ }
202
+ function appleTerminalScript(command) { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
203
+ function iTermScript(command) { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
@@ -1,13 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { lookup } from 'node:dns/promises';
3
3
  import { networkInterfaces } from 'node:os';
4
- import { commandExists } from './command.js';
4
+ import { commandExists } from '../platform/executables.js';
5
+ import { networkPolicy } from '../platform/network.js';
6
+ import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
5
7
  const defaultTimeoutMs = 2500;
6
8
  const defaultMaxDevices = 12;
7
9
  const defaultLookupConcurrency = 4;
8
10
  const defaultMaxOutputBytes = 64 * 1024;
9
11
  export async function discoverAirPlayDevices({ platform = process.platform, timeoutMs = defaultTimeoutMs, maxDevices = defaultMaxDevices, lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes } = {}) {
10
- if (platform !== 'darwin' || !commandExists('dns-sd')) {
12
+ if (networkPolicy().offline || !nativeAdapters(identifyPlatform({ platform })).airPlay || !commandExists('dns-sd')) {
11
13
  return [];
12
14
  }
13
15
  const browseOutput = await runDnsSd(['-B', '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
@@ -1,14 +1,19 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { commandExists } from './command.js';
1
+ import { ffplayInstallCommand, mpvInstallCommand } from '../platform/packages.js';
2
+ import { identifyPlatform, nativeAdapters, readLinuxOsRelease } from '../platform/runtime.js';
3
+ import { commandExists } from '../platform/executables.js';
3
4
  import { airPlaySenderHealth } from './airplay-sender-health.js';
4
5
  export const ffplayLimitedControlsMessage = 'ffplay fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
5
6
  export const vlcLimitedControlsMessage = 'VLC fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
7
+ export const airPlayMacOSOnlyMessage = 'AirPlay output is available only on macOS and is not supported on this operating system.';
8
+ export function isAirPlayPlatformSupported(platform = process.platform) {
9
+ return nativeAdapters(identifyPlatform({ platform })).airPlay;
10
+ }
6
11
  export function detectPlaybackBackends({ platform = process.platform, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
7
12
  const backends = ['mpv', 'ffplay'].filter(hasCommand);
8
13
  if (hasCommand('cvlc') || hasCommand('vlc')) {
9
14
  backends.push('vlc');
10
15
  }
11
- if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) {
16
+ if (isAirPlayPlatformSupported(platform) && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) {
12
17
  backends.push('airplay');
13
18
  }
14
19
  return backends;
@@ -69,16 +74,16 @@ export function playbackBackendCapabilities(backend) {
69
74
  export function playbackBackendLabel(backend) {
70
75
  return playbackBackendCapabilities(backend).label;
71
76
  }
72
- export function playbackBackendInstallHint(platform = process.platform, osRelease = readLinuxOsRelease()) {
73
- return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`;
77
+ export function playbackBackendInstallHint(platform = process.platform, osRelease = readLinuxOsRelease(platform), options = {}) {
78
+ return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease, process.env, options)}), then run radiocli doctor.`;
74
79
  }
75
- export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease()) {
80
+ export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease(platform), options = {}) {
76
81
  const backendSet = new Set(backends);
77
82
  const lines = [
78
83
  'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg',
79
84
  'guided_setup=radiocli setup',
80
- `install_mpv=${mpvInstallCommand(platform, osRelease)}`,
81
- `optional_ffplay=${ffplayInstallCommand(platform, osRelease)}`
85
+ `install_mpv=${mpvInstallCommand(platform, osRelease, process.env, options)}`,
86
+ `optional_ffplay=${ffplayInstallCommand(platform, osRelease, process.env, options)}`
82
87
  ];
83
88
  if (backendSet.has('mpv')) {
84
89
  return [
@@ -123,89 +128,3 @@ export function playbackBackendStatusLines(backends, platform = process.platform
123
128
  ...lines
124
129
  ];
125
130
  }
126
- export function mpvInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease()) {
127
- if (platform === 'darwin') {
128
- return 'brew install mpv';
129
- }
130
- if (platform === 'win32') {
131
- return 'winget install --id shinchiro.mpv -e';
132
- }
133
- if (platform !== 'linux') {
134
- return 'install mpv with your system package manager';
135
- }
136
- const ids = linuxReleaseIds(osRelease);
137
- if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) {
138
- return 'sudo apt install mpv';
139
- }
140
- if (hasAny(ids, ['fedora', 'rhel', 'centos'])) {
141
- return 'sudo dnf install mpv';
142
- }
143
- if (hasAny(ids, ['arch', 'manjaro'])) {
144
- return 'sudo pacman -S mpv';
145
- }
146
- if (hasAny(ids, ['alpine'])) {
147
- return 'sudo apk add mpv';
148
- }
149
- if (hasAny(ids, ['opensuse', 'suse'])) {
150
- return 'sudo zypper install mpv';
151
- }
152
- return 'install mpv with your system package manager';
153
- }
154
- function ffplayInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease()) {
155
- if (platform === 'darwin') {
156
- return 'brew install ffmpeg';
157
- }
158
- if (platform === 'win32') {
159
- return 'winget install --id Gyan.FFmpeg -e';
160
- }
161
- if (platform !== 'linux') {
162
- return 'install FFmpeg with your system package manager';
163
- }
164
- const ids = linuxReleaseIds(osRelease);
165
- if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) {
166
- return 'sudo apt install ffmpeg';
167
- }
168
- if (hasAny(ids, ['fedora', 'rhel', 'centos'])) {
169
- return 'sudo dnf install ffmpeg';
170
- }
171
- if (hasAny(ids, ['arch', 'manjaro'])) {
172
- return 'sudo pacman -S ffmpeg';
173
- }
174
- if (hasAny(ids, ['alpine'])) {
175
- return 'sudo apk add ffmpeg';
176
- }
177
- if (hasAny(ids, ['opensuse', 'suse'])) {
178
- return 'sudo zypper install ffmpeg';
179
- }
180
- return 'install FFmpeg with your system package manager';
181
- }
182
- function readLinuxOsRelease() {
183
- if (process.platform !== 'linux' || !existsSync('/etc/os-release')) {
184
- return '';
185
- }
186
- try {
187
- return readFileSync('/etc/os-release', 'utf8');
188
- }
189
- catch {
190
- return '';
191
- }
192
- }
193
- function linuxReleaseIds(osRelease) {
194
- const ids = new Set();
195
- for (const line of osRelease.split('\n')) {
196
- const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
197
- if (!match) {
198
- continue;
199
- }
200
- for (const value of match[2].replaceAll('"', '').split(/\s+/)) {
201
- const normalized = value.trim().toLowerCase();
202
- if (normalized) {
203
- ids.add(normalized);
204
- }
205
- }
206
- }
207
- return ids;
208
- }
209
- function hasAny(values, candidates) {
210
- return candidates.some(candidate => values.has(candidate));
211
- }
@@ -1,9 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { resolveCommandDetails } from './command.js';
2
+ import { resolveCommandDetails } from '../platform/executables.js';
3
3
  export function diagnoseCommand(command, overrides = {}) {
4
4
  const resolution = (overrides.resolve ?? resolveCommandDetails)(command);
5
5
  if (!resolution.path) {
6
- return { ...resolution, launchable: false, version: null, error: 'not found' };
6
+ return { ...resolution, launchable: false, version: null, error: resolution.error ?? 'not found' };
7
7
  }
8
8
  const execute = overrides.execute ?? ((path, args, options) => spawnSync(path, args, options));
9
9
  const result = execute(resolution.path, ['--version'], {
@@ -102,7 +102,8 @@ export class MpvIpcClient {
102
102
  this.socket = socket;
103
103
  this.connectingSocket = null;
104
104
  this.buffer = '';
105
- socket.on('data', chunk => this.consume(chunk.toString('utf8')));
105
+ socket.setEncoding('utf8');
106
+ socket.on('data', chunk => this.consume(String(chunk)));
106
107
  socket.on('error', error => this.dropSocket(socket, error));
107
108
  socket.on('close', () => this.dropSocket(socket, new Error('mpv IPC connection closed.')));
108
109
  resolve(socket);