@ciphore/radiocli 0.2.4 → 0.2.5
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 +70 -2
- package/CONTRIBUTING.md +10 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +178 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +249 -76
- package/dist/ui/AdaptiveContent.js +18 -10
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
export async function renderTui(printHelp, encodedCommand) {
|
|
3
|
+
if (!process.stdin.isTTY) {
|
|
4
|
+
console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).');
|
|
5
|
+
printHelp();
|
|
6
|
+
process.exitCode = 1;
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const { configureTerminalRenderer } = await import('../ui/terminal-renderer.js');
|
|
10
|
+
const terminal = await configureTerminalRenderer(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
|
|
11
|
+
const { rtlSafeOutput } = await import('../ui/rtl-output.js');
|
|
12
|
+
const [{ render }, { App }, { decodeAgentCommand }, { openTuiSession }] = await Promise.all([import('ink'), import('../ui/App.js'), import('../agent/service.js'), import('../agent/tui-session.js')]);
|
|
13
|
+
const initialAgentCommand = encodedCommand ? decodeAgentCommand(encodedCommand) : undefined;
|
|
14
|
+
const { session: playbackSession, warning: playbackRecoveryWarning } = await openTuiSession();
|
|
15
|
+
try {
|
|
16
|
+
const app = render(_jsx(App, { playbackSession: playbackSession, playbackRecoveryWarning: playbackRecoveryWarning, initialAgentCommand: initialAgentCommand }), {
|
|
17
|
+
stdout: terminal.interactive && process.stdout.isTTY && !terminal.screenReader ? rtlSafeOutput(process.stdout) : process.stdout,
|
|
18
|
+
// App owns Ctrl+C so it can confirm before performing a clean shutdown.
|
|
19
|
+
exitOnCtrlC: false,
|
|
20
|
+
isScreenReaderEnabled: terminal.screenReader,
|
|
21
|
+
interactive: terminal.interactive ? undefined : false,
|
|
22
|
+
debug: !terminal.interactive,
|
|
23
|
+
kittyKeyboard: { mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
|
|
24
|
+
});
|
|
25
|
+
await app.waitUntilExit();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
playbackSession?.disconnect();
|
|
29
|
+
if (playbackSession)
|
|
30
|
+
console.error('The TUI closed unexpectedly. Playback continues; reopen radiocli to reconnect, or run radiocli stop.');
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
await playbackSession?.close();
|
|
34
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,38 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
4
3
|
import { resolve } from 'node:path';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { ProviderManager } from './providers/provider-manager.js';
|
|
7
|
-
import { PlayerController } from './player/player-controller.js';
|
|
8
|
-
import { defaultLibraryPath, JsonLibraryStore } from './storage/store.js';
|
|
9
|
-
import { parsePlaylistFile, writeM3u } from './playlists/playlist.js';
|
|
10
|
-
import { importStreamUrl } from './streams/import-stream.js';
|
|
11
|
-
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
12
|
-
import { resolveCommand } from './platform/executables.js';
|
|
13
|
-
import { diagnoseCommand } from './player/command-diagnostics.js';
|
|
14
|
-
import { airPlaySenderHealth } from './player/airplay-sender-health.js';
|
|
15
5
|
import { appVersion } from './version.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { createSchedulerAdapter } from './alarms/scheduler.js';
|
|
19
|
-
import { runSetup } from './setup.js';
|
|
20
|
-
import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
|
|
21
|
-
import { decodeAgentCommand } from './agent/service.js';
|
|
22
|
-
import { runHeadlessAgentHost } from './agent/headless-host.js';
|
|
23
|
-
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
24
|
-
import { defaultAgentControlSettings } from './types.js';
|
|
25
|
-
import { identifyPlatform } from './platform/runtime.js';
|
|
26
|
-
import { platformCapabilities } from './platform/capabilities.js';
|
|
27
|
-
import { hasGraphicalSession } from './platform/desktop.js';
|
|
28
|
-
import { detectPackageManager } from './platform/packages.js';
|
|
29
|
-
import { storageReadiness } from './platform/storage.js';
|
|
30
|
-
import { resolveTerminalCapabilities } from './platform/terminal.js';
|
|
31
|
-
import { networkDiagnostic } from './platform/network.js';
|
|
32
|
-
import { assessPlatformSupport } from './platform/support.js';
|
|
6
|
+
import { completionCandidates, isListenerCommand, listenerCommands, listenerHelp } from './cli/arguments.js';
|
|
7
|
+
import { sanitizeTerminalText } from './safety.js';
|
|
33
8
|
const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
|
|
34
9
|
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
35
10
|
const args = process.argv.slice(2);
|
|
11
|
+
process.stdout.on('error', error => {
|
|
12
|
+
if (error.code === 'EPIPE')
|
|
13
|
+
process.exit(0);
|
|
14
|
+
else
|
|
15
|
+
throw error;
|
|
16
|
+
});
|
|
36
17
|
if (args[0] === 'agent-ui') {
|
|
37
18
|
const encoded = args[1];
|
|
38
19
|
if (!encoded)
|
|
@@ -40,12 +21,14 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
|
40
21
|
await renderTui(encoded);
|
|
41
22
|
}
|
|
42
23
|
else if (args[0] === 'agent-host') {
|
|
24
|
+
const { runHeadlessAgentHost } = await import('./agent/headless-host.js');
|
|
43
25
|
await runHeadlessAgentHost();
|
|
44
26
|
}
|
|
45
27
|
else if (args.length > 0) {
|
|
46
28
|
await runCommand(args).catch(error => {
|
|
47
|
-
|
|
48
|
-
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
console.error(message.split('\n').map(line => sanitizeTerminalText(line) ?? '').join('\n'));
|
|
31
|
+
process.exitCode = error instanceof Error && error.name === 'AbortError' ? 130 : 1;
|
|
49
32
|
});
|
|
50
33
|
}
|
|
51
34
|
else {
|
|
@@ -53,83 +36,115 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
|
53
36
|
}
|
|
54
37
|
}
|
|
55
38
|
async function renderTui(encodedCommand) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
printHelp();
|
|
59
|
-
process.exitCode = 1;
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const { configureTerminalRenderer } = await import('./ui/terminal-renderer.js');
|
|
63
|
-
const terminal = await configureTerminalRenderer(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
|
|
64
|
-
const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
|
|
65
|
-
render(_jsx(App, { initialAgentCommand: encodedCommand ? decodeAgentCommand(encodedCommand) : undefined }), {
|
|
66
|
-
// App owns Ctrl+C so it can confirm before performing a clean shutdown.
|
|
67
|
-
exitOnCtrlC: false,
|
|
68
|
-
isScreenReaderEnabled: terminal.screenReader,
|
|
69
|
-
interactive: terminal.interactive ? undefined : false,
|
|
70
|
-
debug: !terminal.interactive,
|
|
71
|
-
kittyKeyboard: { mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
|
|
72
|
-
});
|
|
39
|
+
const { renderTui: render } = await import('./cli/render-tui.js');
|
|
40
|
+
await render(printHelp, encodedCommand);
|
|
73
41
|
}
|
|
74
42
|
export async function runCommand(args) {
|
|
75
43
|
const [command, ...rest] = args;
|
|
76
44
|
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
77
|
-
|
|
45
|
+
if (command === 'help' && rest[0] && rest[0] !== '--all') {
|
|
46
|
+
await runCommand([rest[0], '--help']);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
printHelp(rest.includes('--all'));
|
|
78
50
|
return;
|
|
79
51
|
}
|
|
80
52
|
if (command === 'version' || command === '--version' || command === '-v') {
|
|
81
53
|
console.log(appVersion());
|
|
82
54
|
return;
|
|
83
55
|
}
|
|
56
|
+
if (command === '_complete') {
|
|
57
|
+
let presets = [];
|
|
58
|
+
if (rest.length > 1 && ['play', 'favorite', 'unfavorite', 'preset'].includes(rest[0])) {
|
|
59
|
+
try {
|
|
60
|
+
// Completion is read-only, including when a library needs repair.
|
|
61
|
+
const { defaultLibraryPath } = await import('./storage/store.js');
|
|
62
|
+
const library = JSON.parse(readFileSync(defaultLibraryPath(), 'utf8'));
|
|
63
|
+
if (Array.isArray(library.presets))
|
|
64
|
+
presets = library.presets.flatMap(preset => typeof preset?.name === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(preset.name) ? [preset.name] : []);
|
|
65
|
+
}
|
|
66
|
+
catch { /* Completion must remain quiet when storage is unavailable. */ }
|
|
67
|
+
}
|
|
68
|
+
const candidates = completionCandidates(rest, presets);
|
|
69
|
+
if (candidates.length)
|
|
70
|
+
console.log(candidates.join('\n'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const optionArgs = rest.includes('--') ? rest.slice(0, rest.indexOf('--')) : rest;
|
|
74
|
+
const wantsHelp = optionArgs.includes('--help') || optionArgs.includes('-h');
|
|
75
|
+
if (isListenerCommand(command)) {
|
|
76
|
+
if (wantsHelp)
|
|
77
|
+
console.log(listenerHelp(command));
|
|
78
|
+
else {
|
|
79
|
+
const { runListenerCommand } = await import('./cli/listener.js');
|
|
80
|
+
await runListenerCommand(command, rest, runtime);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (command === 'agent' && wantsHelp) {
|
|
85
|
+
const { agentCommandUsage } = await import('./agent/cli.js');
|
|
86
|
+
console.log(`${agentCommandUsage()}\nJSON controls for local agents. Everyday listening is available directly: radiocli play, now, favorites.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const commandHelp = legacyCommandHelp()[command];
|
|
90
|
+
if (wantsHelp && commandHelp) {
|
|
91
|
+
console.log(commandHelp);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
84
94
|
if (command === 'alarm') {
|
|
95
|
+
const { runAlarmCommand } = await import('./alarms/cli.js');
|
|
85
96
|
await runAlarmCommand(rest);
|
|
86
97
|
return;
|
|
87
98
|
}
|
|
88
99
|
if (command === 'mcp') {
|
|
100
|
+
const { runMcpCommand } = await import('./agent/cli.js');
|
|
89
101
|
await runMcpCommand(rest, runtime);
|
|
90
102
|
return;
|
|
91
103
|
}
|
|
92
104
|
if (command === 'agent') {
|
|
105
|
+
const { runAgentCliCommand } = await import('./agent/cli.js');
|
|
93
106
|
await runAgentCliCommand(rest, runtime);
|
|
94
107
|
return;
|
|
95
108
|
}
|
|
96
109
|
if (command === 'setup') {
|
|
97
110
|
if (rest.includes('--help') || rest.includes('-h')) {
|
|
98
|
-
printSetupHelp();
|
|
111
|
+
await printSetupHelp();
|
|
99
112
|
return;
|
|
100
113
|
}
|
|
114
|
+
const { runSetup } = await import('./setup.js');
|
|
101
115
|
await runSetup({ args: rest });
|
|
102
116
|
return;
|
|
103
117
|
}
|
|
118
|
+
if (command === 'media-controls') {
|
|
119
|
+
if (rest.length > 1 || rest[0] !== undefined && !['auto', 'on', 'off'].includes(rest[0])) {
|
|
120
|
+
throw new Error('Usage: radiocli media-controls [auto|on|off]');
|
|
121
|
+
}
|
|
122
|
+
const [{ JsonLibraryStore }, { desktopMediaPolicy }] = await Promise.all([import('./storage/store.js'), import('./platform/desktop-media.js')]);
|
|
123
|
+
const store = new JsonLibraryStore();
|
|
124
|
+
if (rest[0])
|
|
125
|
+
store.updateSettings({ desktopMediaControls: rest[0] });
|
|
126
|
+
const policy = desktopMediaPolicy(store.snapshot().settings);
|
|
127
|
+
console.log(`desktop_media_preference=${store.snapshot().settings.desktopMediaControls ?? 'auto'}`);
|
|
128
|
+
console.log(`desktop_media_effective=${policy.preference} ${policy.enabled ? 'enabled' : 'disabled'}`);
|
|
129
|
+
console.log(`desktop_media_note=${policy.reason}`);
|
|
130
|
+
if (rest[0])
|
|
131
|
+
console.log('Restart active RadioCLI sessions to apply this preference. RADIOCLI_DESKTOP_MEDIA=off overrides the saved preference.');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
104
134
|
if (!isKnownCommand(command)) {
|
|
105
|
-
|
|
135
|
+
const suggestion = suggestCommand(command);
|
|
136
|
+
throw new Error(`Unknown command: ${command}${suggestion ? `\nDid you mean radiocli ${suggestion}?` : ''}\nRun radiocli help.`);
|
|
106
137
|
}
|
|
107
138
|
if (command === 'doctor') {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const report = await doctorReport(backends, mpvDiagnostic);
|
|
111
|
-
if (rest.includes('--json')) {
|
|
112
|
-
console.log(JSON.stringify(report, null, 2));
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
116
|
-
printMpvDiagnostic(mpvDiagnostic);
|
|
117
|
-
printPlaybackBackendStatus(backends);
|
|
118
|
-
for (const [name, capability] of Object.entries(report.capabilities)) {
|
|
119
|
-
console.log(`capability_${name}=${capability.status} ${capability.message}`);
|
|
120
|
-
}
|
|
121
|
-
console.log(`network=${report.network.status} ${report.network.message}`);
|
|
122
|
-
console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`);
|
|
123
|
-
console.log(`support_scope=${report.support.scope}`);
|
|
124
|
-
console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.');
|
|
125
|
-
for (const reason of report.support.reasons)
|
|
126
|
-
console.log(`support_note=${reason}`);
|
|
139
|
+
const { runDoctor } = await import('./cli/doctor.js');
|
|
140
|
+
await runDoctor(rest);
|
|
127
141
|
return;
|
|
128
142
|
}
|
|
129
143
|
if (command === 'update') {
|
|
130
144
|
const unknown = rest.filter(arg => arg !== '--install');
|
|
131
145
|
if (unknown.length > 0)
|
|
132
146
|
throw new Error('Usage: radiocli update [--install]');
|
|
147
|
+
const { checkForUpdate, installUpdate, updateCommandForInstall } = await import('./update-check.js');
|
|
133
148
|
const updateCheck = await checkForUpdate();
|
|
134
149
|
const updateCommand = updateCommandForInstall();
|
|
135
150
|
if (updateCheck.error) {
|
|
@@ -146,9 +161,11 @@ export async function runCommand(args) {
|
|
|
146
161
|
if (!result.ok)
|
|
147
162
|
throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
|
|
148
163
|
console.log('updated=yes');
|
|
164
|
+
const [{ JsonLibraryStore }, { defaultAgentControlSettings }] = await Promise.all([import('./storage/store.js'), import('./types.js')]);
|
|
149
165
|
const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
150
166
|
if (agentControl.enabled) {
|
|
151
167
|
try {
|
|
168
|
+
const { configureMcpIntegrations } = await import('./agent/mcp-install.js');
|
|
152
169
|
const repaired = await configureMcpIntegrations(true, runtime);
|
|
153
170
|
const failed = repaired.filter(item => item.status === 'failed');
|
|
154
171
|
console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
|
|
@@ -164,6 +181,9 @@ export async function runCommand(args) {
|
|
|
164
181
|
return;
|
|
165
182
|
}
|
|
166
183
|
if (command === 'check') {
|
|
184
|
+
const [{ JsonLibraryStore }, { ProviderManager }, { PlayerController }, { playbackBackendStatusLines }] = await Promise.all([
|
|
185
|
+
import('./storage/store.js'), import('./providers/provider-manager.js'), import('./player/player-controller.js'), import('./player/backend-install.js')
|
|
186
|
+
]);
|
|
167
187
|
const store = new JsonLibraryStore();
|
|
168
188
|
const providers = new ProviderManager();
|
|
169
189
|
const player = new PlayerController(() => store.snapshot().settings);
|
|
@@ -171,40 +191,22 @@ export async function runCommand(args) {
|
|
|
171
191
|
const health = await providers.health(store.snapshot().settings);
|
|
172
192
|
console.log(`store=${store.filePath}`);
|
|
173
193
|
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
174
|
-
|
|
194
|
+
for (const line of playbackBackendStatusLines(backends))
|
|
195
|
+
console.log(line);
|
|
175
196
|
for (const [provider, status] of Object.entries(health)) {
|
|
176
197
|
console.log(`${provider}=${status}`);
|
|
177
198
|
}
|
|
178
199
|
return;
|
|
179
200
|
}
|
|
180
|
-
if (command === 'countries') {
|
|
181
|
-
const providers = new ProviderManager();
|
|
182
|
-
const countries = await providers.countries(30);
|
|
183
|
-
for (const country of countries) {
|
|
184
|
-
console.log(`${country.code}\t${country.stationCount}\t${country.name}`);
|
|
185
|
-
}
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
if (command === 'search') {
|
|
189
|
-
const query = rest.join(' ').trim();
|
|
190
|
-
if (!query) {
|
|
191
|
-
throw new Error('Usage: radiocli search <query>');
|
|
192
|
-
}
|
|
193
|
-
const store = new JsonLibraryStore();
|
|
194
|
-
const providers = new ProviderManager();
|
|
195
|
-
const stations = await providers.search(query, store.snapshot().settings, { limit: 20 });
|
|
196
|
-
for (const station of stations) {
|
|
197
|
-
console.log(`${station.provider}:${station.id}\t${station.name}\t${station.country ?? ''}\t${station.codec ?? ''}\t${station.bitrate ?? ''}`);
|
|
198
|
-
}
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
201
|
if (command === 'import') {
|
|
202
202
|
const target = rest[0];
|
|
203
203
|
if (!target) {
|
|
204
204
|
throw new Error('Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]');
|
|
205
205
|
}
|
|
206
|
+
const { JsonLibraryStore } = await import('./storage/store.js');
|
|
206
207
|
const store = new JsonLibraryStore();
|
|
207
208
|
if (/^https?:\/\//i.test(target)) {
|
|
209
|
+
const { importStreamUrl } = await import('./streams/import-stream.js');
|
|
208
210
|
const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined);
|
|
209
211
|
store.addImported([result.station]);
|
|
210
212
|
console.log(`imported=${result.station.name}`);
|
|
@@ -212,12 +214,14 @@ export async function runCommand(args) {
|
|
|
212
214
|
console.log(`warning=${result.warning}`);
|
|
213
215
|
return;
|
|
214
216
|
}
|
|
217
|
+
const { parsePlaylistFile } = await import('./playlists/playlist.js');
|
|
215
218
|
const stations = parsePlaylistFile(target);
|
|
216
219
|
store.addImported(stations);
|
|
217
220
|
console.log(`imported=${stations.length}`);
|
|
218
221
|
return;
|
|
219
222
|
}
|
|
220
223
|
if (command === 'export') {
|
|
224
|
+
const [{ JsonLibraryStore }, { writeM3u }] = await Promise.all([import('./storage/store.js'), import('./playlists/playlist.js')]);
|
|
221
225
|
const file = rest[0] ?? 'radiocli-favorites.m3u';
|
|
222
226
|
const store = new JsonLibraryStore();
|
|
223
227
|
const state = store.snapshot();
|
|
@@ -226,25 +230,90 @@ export async function runCommand(args) {
|
|
|
226
230
|
return;
|
|
227
231
|
}
|
|
228
232
|
}
|
|
229
|
-
export function printHelp() {
|
|
233
|
+
export function printHelp(all = false) {
|
|
230
234
|
console.log(`RadioCLI
|
|
231
235
|
|
|
232
|
-
Usage:
|
|
233
|
-
radiocli
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
radiocli
|
|
237
|
-
radiocli
|
|
238
|
-
radiocli
|
|
239
|
-
radiocli
|
|
240
|
-
radiocli
|
|
241
|
-
radiocli
|
|
242
|
-
radiocli
|
|
243
|
-
radiocli
|
|
236
|
+
Usage: radiocli [command]
|
|
237
|
+
Run radiocli without a command to open the TUI.
|
|
238
|
+
|
|
239
|
+
Everyday listening:
|
|
240
|
+
radiocli play [station] Start or continue listening; names and presets work
|
|
241
|
+
radiocli pause Pause; use play to continue
|
|
242
|
+
radiocli next Tune the next station in the current queue
|
|
243
|
+
radiocli now See the current station, track, and volume
|
|
244
|
+
radiocli volume <0-100> Set listening volume
|
|
245
|
+
radiocli favorite Save the current station
|
|
246
|
+
radiocli favorites List saved stations; add --pick to choose one
|
|
247
|
+
radiocli stop Stop playback and close the background player
|
|
248
|
+
${all ? `
|
|
249
|
+
More listening controls:
|
|
250
|
+
radiocli previous Tune the previous station in the queue
|
|
251
|
+
radiocli resume Resume paused playback (play also works)
|
|
252
|
+
radiocli mute | unmute Mute or restore sound
|
|
253
|
+
radiocli now --watch Watch station and track changes
|
|
254
|
+
radiocli status Alias for now
|
|
255
|
+
radiocli play --favorites | --recent | --popular
|
|
256
|
+
Choose a queue; add --random or --pick
|
|
257
|
+
|
|
258
|
+
Discovery and library:
|
|
259
|
+
radiocli search <query> Search stations; --country, --limit, --pick
|
|
260
|
+
radiocli countries List top countries
|
|
261
|
+
radiocli recent List recently played stations
|
|
262
|
+
radiocli unfavorite Remove a favorite (current station by default)
|
|
263
|
+
radiocli preset save <name> Save a shortcut; play with play <name>
|
|
264
|
+
radiocli preset remove <name> Remove a shortcut
|
|
265
|
+
radiocli presets List shortcuts; also: preset list
|
|
266
|
+
radiocli stats Show listening time and streaks
|
|
244
267
|
radiocli import <target> Import a stream URL or local playlist
|
|
245
|
-
radiocli export [file]
|
|
268
|
+
radiocli export [file] Export favorites/imports as .m3u
|
|
246
269
|
radiocli alarm <command> Manage alarms and scheduled radio
|
|
247
|
-
|
|
270
|
+
|
|
271
|
+
Setup and integrations:
|
|
272
|
+
radiocli setup Install and verify native playback tools
|
|
273
|
+
radiocli doctor [--json] Show local playback setup guidance
|
|
274
|
+
radiocli check Show provider/backend health
|
|
275
|
+
radiocli version Print the installed version
|
|
276
|
+
radiocli update Check for updates; --install to install
|
|
277
|
+
radiocli media-controls [auto|on|off] Configure Linux desktop media controls
|
|
278
|
+
radiocli mcp <command> Install, inspect, or run the MCP integration
|
|
279
|
+
radiocli agent <command> Scriptable radio controls for local agents
|
|
280
|
+
` : ''}
|
|
281
|
+
Examples:
|
|
282
|
+
radiocli play jazz Find a station and listen
|
|
283
|
+
radiocli play Continue the current or last station
|
|
284
|
+
radiocli play --favorites Listen from your favorites
|
|
285
|
+
|
|
286
|
+
More help:
|
|
287
|
+
radiocli help --all Browse every command by category
|
|
288
|
+
radiocli help play Options and examples for one command
|
|
289
|
+
radiocli completion <shell> Print a Tab completion script
|
|
290
|
+
${all ? '\nListener commands support --json, --plain, --quiet, and --no-input.\nUse radiocli <command> --help for options and examples.\n' : ''}
|
|
291
|
+
`.trimEnd());
|
|
292
|
+
}
|
|
293
|
+
function legacyCommandHelp() {
|
|
294
|
+
return {
|
|
295
|
+
check: 'Usage: radiocli check\nShow local store paths, playback backends, and provider health.',
|
|
296
|
+
doctor: 'Usage: radiocli doctor [--json] [--playback]\nCheck playback tools without changing settings. Add --playback to rehearse local WAV playback through null/dummy audio.',
|
|
297
|
+
update: 'Usage: radiocli update [--install]\nCheck for updates. Add --install to install and repair enabled MCP entries.',
|
|
298
|
+
import: 'Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]\nExample: radiocli import https://example.com/live.mp3 "Example FM"',
|
|
299
|
+
export: 'Usage: radiocli export [file]\nExport favorites and imports as M3U (default: radiocli-favorites.m3u).',
|
|
300
|
+
'media-controls': 'Usage: radiocli media-controls [auto|on|off]\nInspect or configure Linux desktop media controls.',
|
|
301
|
+
mcp: 'Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>\nManage the optional local agent integration.'
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function suggestCommand(input) {
|
|
305
|
+
const candidates = [...listenerCommands, ...Object.keys(legacyCommandHelp()), 'setup', 'alarm', 'agent', 'help', 'version'];
|
|
306
|
+
const distance = (word) => {
|
|
307
|
+
let row = Array.from({ length: word.length + 1 }, (_, index) => index);
|
|
308
|
+
for (let i = 1; i <= input.length; i += 1) {
|
|
309
|
+
const next = [i];
|
|
310
|
+
for (let j = 1; j <= word.length; j += 1)
|
|
311
|
+
next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (input[i - 1] === word[j - 1] ? 0 : 1));
|
|
312
|
+
row = next;
|
|
313
|
+
}
|
|
314
|
+
return row[word.length];
|
|
315
|
+
};
|
|
316
|
+
return candidates.map(word => ({ word, distance: distance(word) })).filter(item => item.distance <= (input.length > 3 ? 2 : 1)).sort((a, b) => a.distance - b.distance)[0]?.word;
|
|
248
317
|
}
|
|
249
318
|
export function isDirectRun(entryPath, moduleUrl) {
|
|
250
319
|
if (!entryPath) {
|
|
@@ -260,7 +329,8 @@ export function isDirectRun(entryPath, moduleUrl) {
|
|
|
260
329
|
function isKnownCommand(command) {
|
|
261
330
|
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command);
|
|
262
331
|
}
|
|
263
|
-
function printSetupHelp() {
|
|
332
|
+
async function printSetupHelp() {
|
|
333
|
+
const { isAirPlayPlatformSupported } = await import('./player/airplay-policy.js');
|
|
264
334
|
console.log(`RadioCLI Setup
|
|
265
335
|
|
|
266
336
|
Usage:
|
|
@@ -273,100 +343,9 @@ Usage:
|
|
|
273
343
|
radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
|
|
274
344
|
radiocli setup --mcp --headless-agent Opt out of external terminal windows
|
|
275
345
|
radiocli setup --no-mcp Disable and remove agent MCP entries
|
|
276
|
-
radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
346
|
+
${process.platform === 'linux' && isAirPlayPlatformSupported() ? ' radiocli setup --airplay Enable experimental Linux AirPlay and set up prerequisites\n radiocli setup --no-airplay Disable Linux AirPlay; keep shared services and packages\n' : ''} radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
277
347
|
apt, dnf, pacman, apk, zypper,
|
|
278
348
|
pkg (FreeBSD), pkg_add, pkgin,
|
|
279
349
|
termux-pkg (Termux), or pkgman (Haiku)
|
|
280
350
|
`);
|
|
281
351
|
}
|
|
282
|
-
function printPlaybackBackendStatus(backends) {
|
|
283
|
-
for (const line of playbackBackendStatusLines(backends)) {
|
|
284
|
-
console.log(line);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
async function doctorReport(backends, mpvDiagnostic) {
|
|
288
|
-
const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
|
|
289
|
-
const airPlay = airPlaySenderHealth();
|
|
290
|
-
const host = identifyPlatform();
|
|
291
|
-
const terminal = resolveTerminalCapabilities(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
|
|
292
|
-
let scheduler;
|
|
293
|
-
try {
|
|
294
|
-
const adapter = createSchedulerAdapter();
|
|
295
|
-
scheduler = await adapter.probeCapabilities?.();
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
scheduler = { supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.' };
|
|
299
|
-
}
|
|
300
|
-
const integrationCommands = [
|
|
301
|
-
'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit',
|
|
302
|
-
'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer',
|
|
303
|
-
'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel',
|
|
304
|
-
'termux-open-url', 'termux-clipboard-set'
|
|
305
|
-
].filter(command => resolveCommand(command) !== null);
|
|
306
|
-
const capabilities = platformCapabilities(host, {
|
|
307
|
-
backends,
|
|
308
|
-
commands: integrationCommands,
|
|
309
|
-
graphicalSession: hasGraphicalSession(host),
|
|
310
|
-
packageManager: detectPackageManager(process.platform, host.osRelease),
|
|
311
|
-
storageWritable: libraryWritesAvailable(),
|
|
312
|
-
scheduler,
|
|
313
|
-
unicode: terminal.unicode,
|
|
314
|
-
color: terminal.colorLevel > 0,
|
|
315
|
-
screenReader: terminal.screenReader
|
|
316
|
-
});
|
|
317
|
-
return {
|
|
318
|
-
radioCliVersion: appVersion(),
|
|
319
|
-
nodeVersion: process.version,
|
|
320
|
-
platform: process.platform,
|
|
321
|
-
architecture: process.arch,
|
|
322
|
-
host: { id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl },
|
|
323
|
-
support: { scope: 'current-installation', ...assessPlatformSupport(host) },
|
|
324
|
-
capabilities,
|
|
325
|
-
terminal,
|
|
326
|
-
network: networkDiagnostic(),
|
|
327
|
-
backends,
|
|
328
|
-
commands,
|
|
329
|
-
mpv: redactDiagnostic(mpvDiagnostic),
|
|
330
|
-
airPlay: {
|
|
331
|
-
available: airPlay.available,
|
|
332
|
-
safe: airPlay.safe,
|
|
333
|
-
package: airPlay.packageName,
|
|
334
|
-
version: airPlay.version ?? null,
|
|
335
|
-
vulnerablePackages: airPlay.vulnerablePackages,
|
|
336
|
-
warningPackages: airPlay.warningPackages
|
|
337
|
-
},
|
|
338
|
-
guidance: playbackBackendStatusLines(backends)
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
function libraryWritesAvailable() {
|
|
342
|
-
try {
|
|
343
|
-
return storageReadiness(defaultLibraryPath()).status === 'available';
|
|
344
|
-
}
|
|
345
|
-
catch {
|
|
346
|
-
return false;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
function printMpvDiagnostic(diagnostic) {
|
|
350
|
-
console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`);
|
|
351
|
-
console.log(`mpv_discovery=${diagnostic.discovery}`);
|
|
352
|
-
console.log(`mpv_launch=${diagnostic.launchable ? 'ready' : 'failed'}`);
|
|
353
|
-
if (diagnostic.version)
|
|
354
|
-
console.log(`mpv_version=${diagnostic.version}`);
|
|
355
|
-
if (diagnostic.error)
|
|
356
|
-
console.log(`mpv_error=${diagnostic.error}`);
|
|
357
|
-
if (diagnostic.launchable && diagnostic.discovery !== 'path') {
|
|
358
|
-
console.log('mpv_hint=RadioCLI found mpv outside PATH and can use it directly; no PATH changes are required.');
|
|
359
|
-
}
|
|
360
|
-
else if (!diagnostic.path && process.platform === 'win32') {
|
|
361
|
-
console.log('mpv_hint=Install with winget, then rerun Doctor; RadioCLI also checks the standard MPV Player install directory.');
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
function redactDiagnostic(diagnostic) {
|
|
365
|
-
return { ...diagnostic, path: redactHome(diagnostic.path) };
|
|
366
|
-
}
|
|
367
|
-
function redactHome(path) {
|
|
368
|
-
if (!path)
|
|
369
|
-
return null;
|
|
370
|
-
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
371
|
-
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
372
|
-
}
|
|
@@ -48,6 +48,13 @@ export function platformCapabilities(host, evidence) {
|
|
|
48
48
|
unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'),
|
|
49
49
|
color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'),
|
|
50
50
|
screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'),
|
|
51
|
-
airPlay: adapters.airPlay
|
|
51
|
+
airPlay: !adapters.airPlay ? unavailable('Experimental AirPlay is supported on macOS and native Linux; this environment is not supported.')
|
|
52
|
+
: evidence.airPlay ? evidence.airPlay.ready
|
|
53
|
+
? evidence.airPlay.discoveryService === 'unverified' ? unverified(evidence.airPlay.message, 'airplay') : available(evidence.airPlay.message, 'airplay')
|
|
54
|
+
: unavailable(evidence.airPlay.message)
|
|
55
|
+
: evidence.backends.includes('airplay')
|
|
56
|
+
? host.id === 'linux' ? unverified('Experimental Linux output requires an explicit discovery-service and native-sender check. Select a receiver each session.', 'airplay')
|
|
57
|
+
: available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay')
|
|
58
|
+
: unavailable(`Experimental AirPlay requires FFmpeg, ${host.id === 'linux' ? 'Avahi' : 'Bonjour'}, and the safe optional sender.`)
|
|
52
59
|
};
|
|
53
60
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { planMpvDesktopMedia } from '../player/mpv-desktop-media.js';
|
|
3
|
+
import { identifyOmarchy } from './omarchy.js';
|
|
4
|
+
import { sessionBusSocket, startMpris } from './mpris.js';
|
|
5
|
+
import { resolveCommandDetails } from './executables.js';
|
|
6
|
+
export function desktopMediaPolicy(settings, options = {}) {
|
|
7
|
+
const platform = options.platform ?? process.platform;
|
|
8
|
+
const env = options.env ?? process.env;
|
|
9
|
+
const override = env.RADIOCLI_DESKTOP_MEDIA;
|
|
10
|
+
const preference = override === 'auto' || override === 'on' || override === 'off' ? override : settings.desktopMediaControls ?? 'auto';
|
|
11
|
+
const disabled = (reason) => ({ enabled: false, preference, reason, mpvArgs: [] });
|
|
12
|
+
if (preference === 'off')
|
|
13
|
+
return disabled('Desktop media controls are disabled.');
|
|
14
|
+
if (platform !== 'linux')
|
|
15
|
+
return disabled('Desktop media controls currently require Linux.');
|
|
16
|
+
if (!sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? ''))
|
|
17
|
+
return disabled('A local Unix session bus is required.');
|
|
18
|
+
if (preference === 'auto') {
|
|
19
|
+
const profile = (options.omarchy ?? (() => identifyOmarchy({ platform, env })))();
|
|
20
|
+
if (profile.kind !== 'quattro' || profile.source !== 'package' || !profile.session.localDesktop)
|
|
21
|
+
return disabled('Automatic desktop media controls require a packaged Omarchy Quattro local desktop.');
|
|
22
|
+
}
|
|
23
|
+
const mpv = (options.mpv ?? (() => planMpvDesktopMedia({ platform, env, executable: resolveCommandDetails('mpv', { platform, env }).path })))();
|
|
24
|
+
if (mpv.status !== 'compatible')
|
|
25
|
+
return disabled(mpv.reason);
|
|
26
|
+
return { enabled: true, preference, reason: mpv.reason, mpvArgs: mpv.args };
|
|
27
|
+
}
|
|
28
|
+
/** State and ICY updates reuse the owning player's event stream. No additional
|
|
29
|
+
* playback polling is needed and the bridge never connects through MCP. */
|
|
30
|
+
export function startDesktopMediaControls(options) {
|
|
31
|
+
if (!options.policy.enabled)
|
|
32
|
+
return { refresh() { }, close() { } };
|
|
33
|
+
let title;
|
|
34
|
+
let previousStream;
|
|
35
|
+
let previousStation;
|
|
36
|
+
let disposed = false;
|
|
37
|
+
const bridge = startMpris({
|
|
38
|
+
identity: resolve(options.identity),
|
|
39
|
+
address: (options.env ?? process.env).DBUS_SESSION_BUS_ADDRESS ?? '',
|
|
40
|
+
command: command => options.command(command, () => !disposed),
|
|
41
|
+
snapshot: () => ({ ...options.status(), title, available: !options.suspended() && options.player.desktopMediaCompatible() })
|
|
42
|
+
});
|
|
43
|
+
const refresh = () => {
|
|
44
|
+
const station = options.status().station;
|
|
45
|
+
const key = station ? `${station.provider}\0${station.id}` : undefined;
|
|
46
|
+
if (key !== previousStation) {
|
|
47
|
+
title = undefined;
|
|
48
|
+
previousStation = key;
|
|
49
|
+
}
|
|
50
|
+
bridge.refresh();
|
|
51
|
+
};
|
|
52
|
+
const removeState = options.player.onChange(state => {
|
|
53
|
+
if (state.streamUrl !== previousStream) {
|
|
54
|
+
title = undefined;
|
|
55
|
+
previousStream = state.streamUrl;
|
|
56
|
+
}
|
|
57
|
+
refresh();
|
|
58
|
+
});
|
|
59
|
+
const removeMetadata = options.player.onMetadata(metadata => { title = metadata.title; refresh(); });
|
|
60
|
+
return { refresh, close() { disposed = true; removeState(); removeMetadata(); bridge.close(); } };
|
|
61
|
+
}
|