@ciphore/radiocli 0.2.4 → 0.2.6
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 +103 -2
- package/CONTRIBUTING.md +28 -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 +182 -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 +130 -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 +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- 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/components/ReceiverVisualRow.js +39 -0
- 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/NowPlayingScreen.js +2 -21
- 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/theme.js +46 -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/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- 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,119 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
|
53
36
|
}
|
|
54
37
|
}
|
|
55
38
|
async function renderTui(encodedCommand) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
// React's development profiler retains per-frame timing entries in Node.
|
|
40
|
+
// Choose the production runtime before loading Ink or JSX; explicit developer
|
|
41
|
+
// environments still take precedence.
|
|
42
|
+
process.env.NODE_ENV ??= 'production';
|
|
43
|
+
const { renderTui: render } = await import('./cli/render-tui.js');
|
|
44
|
+
await render(printHelp, encodedCommand);
|
|
73
45
|
}
|
|
74
46
|
export async function runCommand(args) {
|
|
75
47
|
const [command, ...rest] = args;
|
|
76
48
|
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
77
|
-
|
|
49
|
+
if (command === 'help' && rest[0] && rest[0] !== '--all') {
|
|
50
|
+
await runCommand([rest[0], '--help']);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
printHelp(rest.includes('--all'));
|
|
78
54
|
return;
|
|
79
55
|
}
|
|
80
56
|
if (command === 'version' || command === '--version' || command === '-v') {
|
|
81
57
|
console.log(appVersion());
|
|
82
58
|
return;
|
|
83
59
|
}
|
|
60
|
+
if (command === '_complete') {
|
|
61
|
+
let presets = [];
|
|
62
|
+
if (rest.length > 1 && ['play', 'favorite', 'unfavorite', 'preset'].includes(rest[0])) {
|
|
63
|
+
try {
|
|
64
|
+
// Completion is read-only, including when a library needs repair.
|
|
65
|
+
const { defaultLibraryPath } = await import('./storage/store.js');
|
|
66
|
+
const library = JSON.parse(readFileSync(defaultLibraryPath(), 'utf8'));
|
|
67
|
+
if (Array.isArray(library.presets))
|
|
68
|
+
presets = library.presets.flatMap(preset => typeof preset?.name === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(preset.name) ? [preset.name] : []);
|
|
69
|
+
}
|
|
70
|
+
catch { /* Completion must remain quiet when storage is unavailable. */ }
|
|
71
|
+
}
|
|
72
|
+
const candidates = completionCandidates(rest, presets);
|
|
73
|
+
if (candidates.length)
|
|
74
|
+
console.log(candidates.join('\n'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const optionArgs = rest.includes('--') ? rest.slice(0, rest.indexOf('--')) : rest;
|
|
78
|
+
const wantsHelp = optionArgs.includes('--help') || optionArgs.includes('-h');
|
|
79
|
+
if (isListenerCommand(command)) {
|
|
80
|
+
if (wantsHelp)
|
|
81
|
+
console.log(listenerHelp(command));
|
|
82
|
+
else {
|
|
83
|
+
const { runListenerCommand } = await import('./cli/listener.js');
|
|
84
|
+
await runListenerCommand(command, rest, runtime);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (command === 'agent' && wantsHelp) {
|
|
89
|
+
const { agentCommandUsage } = await import('./agent/cli.js');
|
|
90
|
+
console.log(`${agentCommandUsage()}\nJSON controls for local agents. Everyday listening is available directly: radiocli play, now, favorites.`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const commandHelp = legacyCommandHelp()[command];
|
|
94
|
+
if (wantsHelp && commandHelp) {
|
|
95
|
+
console.log(commandHelp);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
84
98
|
if (command === 'alarm') {
|
|
99
|
+
const { runAlarmCommand } = await import('./alarms/cli.js');
|
|
85
100
|
await runAlarmCommand(rest);
|
|
86
101
|
return;
|
|
87
102
|
}
|
|
88
103
|
if (command === 'mcp') {
|
|
104
|
+
const { runMcpCommand } = await import('./agent/cli.js');
|
|
89
105
|
await runMcpCommand(rest, runtime);
|
|
90
106
|
return;
|
|
91
107
|
}
|
|
92
108
|
if (command === 'agent') {
|
|
109
|
+
const { runAgentCliCommand } = await import('./agent/cli.js');
|
|
93
110
|
await runAgentCliCommand(rest, runtime);
|
|
94
111
|
return;
|
|
95
112
|
}
|
|
96
113
|
if (command === 'setup') {
|
|
97
114
|
if (rest.includes('--help') || rest.includes('-h')) {
|
|
98
|
-
printSetupHelp();
|
|
115
|
+
await printSetupHelp();
|
|
99
116
|
return;
|
|
100
117
|
}
|
|
118
|
+
const { runSetup } = await import('./setup.js');
|
|
101
119
|
await runSetup({ args: rest });
|
|
102
120
|
return;
|
|
103
121
|
}
|
|
122
|
+
if (command === 'media-controls') {
|
|
123
|
+
if (rest.length > 1 || rest[0] !== undefined && !['auto', 'on', 'off'].includes(rest[0])) {
|
|
124
|
+
throw new Error('Usage: radiocli media-controls [auto|on|off]');
|
|
125
|
+
}
|
|
126
|
+
const [{ JsonLibraryStore }, { desktopMediaPolicy }] = await Promise.all([import('./storage/store.js'), import('./platform/desktop-media.js')]);
|
|
127
|
+
const store = new JsonLibraryStore();
|
|
128
|
+
if (rest[0])
|
|
129
|
+
store.updateSettings({ desktopMediaControls: rest[0] });
|
|
130
|
+
const policy = desktopMediaPolicy(store.snapshot().settings);
|
|
131
|
+
console.log(`desktop_media_preference=${store.snapshot().settings.desktopMediaControls ?? 'auto'}`);
|
|
132
|
+
console.log(`desktop_media_effective=${policy.preference} ${policy.enabled ? 'enabled' : 'disabled'}`);
|
|
133
|
+
console.log(`desktop_media_note=${policy.reason}`);
|
|
134
|
+
if (rest[0])
|
|
135
|
+
console.log('Restart active RadioCLI sessions to apply this preference. RADIOCLI_DESKTOP_MEDIA=off overrides the saved preference.');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
104
138
|
if (!isKnownCommand(command)) {
|
|
105
|
-
|
|
139
|
+
const suggestion = suggestCommand(command);
|
|
140
|
+
throw new Error(`Unknown command: ${command}${suggestion ? `\nDid you mean radiocli ${suggestion}?` : ''}\nRun radiocli help.`);
|
|
106
141
|
}
|
|
107
142
|
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}`);
|
|
143
|
+
const { runDoctor } = await import('./cli/doctor.js');
|
|
144
|
+
await runDoctor(rest);
|
|
127
145
|
return;
|
|
128
146
|
}
|
|
129
147
|
if (command === 'update') {
|
|
130
148
|
const unknown = rest.filter(arg => arg !== '--install');
|
|
131
149
|
if (unknown.length > 0)
|
|
132
150
|
throw new Error('Usage: radiocli update [--install]');
|
|
151
|
+
const { checkForUpdate, installUpdate, updateCommandForInstall } = await import('./update-check.js');
|
|
133
152
|
const updateCheck = await checkForUpdate();
|
|
134
153
|
const updateCommand = updateCommandForInstall();
|
|
135
154
|
if (updateCheck.error) {
|
|
@@ -146,9 +165,11 @@ export async function runCommand(args) {
|
|
|
146
165
|
if (!result.ok)
|
|
147
166
|
throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
|
|
148
167
|
console.log('updated=yes');
|
|
168
|
+
const [{ JsonLibraryStore }, { defaultAgentControlSettings }] = await Promise.all([import('./storage/store.js'), import('./types.js')]);
|
|
149
169
|
const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
150
170
|
if (agentControl.enabled) {
|
|
151
171
|
try {
|
|
172
|
+
const { configureMcpIntegrations } = await import('./agent/mcp-install.js');
|
|
152
173
|
const repaired = await configureMcpIntegrations(true, runtime);
|
|
153
174
|
const failed = repaired.filter(item => item.status === 'failed');
|
|
154
175
|
console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
|
|
@@ -164,6 +185,9 @@ export async function runCommand(args) {
|
|
|
164
185
|
return;
|
|
165
186
|
}
|
|
166
187
|
if (command === 'check') {
|
|
188
|
+
const [{ JsonLibraryStore }, { ProviderManager }, { PlayerController }, { playbackBackendStatusLines }] = await Promise.all([
|
|
189
|
+
import('./storage/store.js'), import('./providers/provider-manager.js'), import('./player/player-controller.js'), import('./player/backend-install.js')
|
|
190
|
+
]);
|
|
167
191
|
const store = new JsonLibraryStore();
|
|
168
192
|
const providers = new ProviderManager();
|
|
169
193
|
const player = new PlayerController(() => store.snapshot().settings);
|
|
@@ -171,40 +195,22 @@ export async function runCommand(args) {
|
|
|
171
195
|
const health = await providers.health(store.snapshot().settings);
|
|
172
196
|
console.log(`store=${store.filePath}`);
|
|
173
197
|
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
174
|
-
|
|
198
|
+
for (const line of playbackBackendStatusLines(backends))
|
|
199
|
+
console.log(line);
|
|
175
200
|
for (const [provider, status] of Object.entries(health)) {
|
|
176
201
|
console.log(`${provider}=${status}`);
|
|
177
202
|
}
|
|
178
203
|
return;
|
|
179
204
|
}
|
|
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
205
|
if (command === 'import') {
|
|
202
206
|
const target = rest[0];
|
|
203
207
|
if (!target) {
|
|
204
208
|
throw new Error('Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]');
|
|
205
209
|
}
|
|
210
|
+
const { JsonLibraryStore } = await import('./storage/store.js');
|
|
206
211
|
const store = new JsonLibraryStore();
|
|
207
212
|
if (/^https?:\/\//i.test(target)) {
|
|
213
|
+
const { importStreamUrl } = await import('./streams/import-stream.js');
|
|
208
214
|
const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined);
|
|
209
215
|
store.addImported([result.station]);
|
|
210
216
|
console.log(`imported=${result.station.name}`);
|
|
@@ -212,12 +218,14 @@ export async function runCommand(args) {
|
|
|
212
218
|
console.log(`warning=${result.warning}`);
|
|
213
219
|
return;
|
|
214
220
|
}
|
|
221
|
+
const { parsePlaylistFile } = await import('./playlists/playlist.js');
|
|
215
222
|
const stations = parsePlaylistFile(target);
|
|
216
223
|
store.addImported(stations);
|
|
217
224
|
console.log(`imported=${stations.length}`);
|
|
218
225
|
return;
|
|
219
226
|
}
|
|
220
227
|
if (command === 'export') {
|
|
228
|
+
const [{ JsonLibraryStore }, { writeM3u }] = await Promise.all([import('./storage/store.js'), import('./playlists/playlist.js')]);
|
|
221
229
|
const file = rest[0] ?? 'radiocli-favorites.m3u';
|
|
222
230
|
const store = new JsonLibraryStore();
|
|
223
231
|
const state = store.snapshot();
|
|
@@ -226,25 +234,90 @@ export async function runCommand(args) {
|
|
|
226
234
|
return;
|
|
227
235
|
}
|
|
228
236
|
}
|
|
229
|
-
export function printHelp() {
|
|
237
|
+
export function printHelp(all = false) {
|
|
230
238
|
console.log(`RadioCLI
|
|
231
239
|
|
|
232
|
-
Usage:
|
|
233
|
-
radiocli
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
radiocli
|
|
237
|
-
radiocli
|
|
238
|
-
radiocli
|
|
239
|
-
radiocli
|
|
240
|
-
radiocli
|
|
241
|
-
radiocli
|
|
242
|
-
radiocli
|
|
243
|
-
radiocli
|
|
240
|
+
Usage: radiocli [command]
|
|
241
|
+
Run radiocli without a command to open the TUI.
|
|
242
|
+
|
|
243
|
+
Everyday listening:
|
|
244
|
+
radiocli play [station] Start or continue listening; names and presets work
|
|
245
|
+
radiocli pause Pause; use play to continue
|
|
246
|
+
radiocli next Tune the next station in the current queue
|
|
247
|
+
radiocli now See the current station, track, and volume
|
|
248
|
+
radiocli volume <0-100> Set listening volume
|
|
249
|
+
radiocli favorite Save the current station
|
|
250
|
+
radiocli favorites List saved stations; add --pick to choose one
|
|
251
|
+
radiocli stop Stop playback and close the background player
|
|
252
|
+
${all ? `
|
|
253
|
+
More listening controls:
|
|
254
|
+
radiocli previous Tune the previous station in the queue
|
|
255
|
+
radiocli resume Resume paused playback (play also works)
|
|
256
|
+
radiocli mute | unmute Mute or restore sound
|
|
257
|
+
radiocli now --watch Watch station and track changes
|
|
258
|
+
radiocli status Alias for now
|
|
259
|
+
radiocli play --favorites | --recent | --popular
|
|
260
|
+
Choose a queue; add --random or --pick
|
|
261
|
+
|
|
262
|
+
Discovery and library:
|
|
263
|
+
radiocli search <query> Search stations; --country, --limit, --pick
|
|
264
|
+
radiocli countries List top countries
|
|
265
|
+
radiocli recent List recently played stations
|
|
266
|
+
radiocli unfavorite Remove a favorite (current station by default)
|
|
267
|
+
radiocli preset save <name> Save a shortcut; play with play <name>
|
|
268
|
+
radiocli preset remove <name> Remove a shortcut
|
|
269
|
+
radiocli presets List shortcuts; also: preset list
|
|
270
|
+
radiocli stats Show listening time and streaks
|
|
244
271
|
radiocli import <target> Import a stream URL or local playlist
|
|
245
|
-
radiocli export [file]
|
|
272
|
+
radiocli export [file] Export favorites/imports as .m3u
|
|
246
273
|
radiocli alarm <command> Manage alarms and scheduled radio
|
|
247
|
-
|
|
274
|
+
|
|
275
|
+
Setup and integrations:
|
|
276
|
+
radiocli setup Install and verify native playback tools
|
|
277
|
+
radiocli doctor [--json] Show local playback setup guidance
|
|
278
|
+
radiocli check Show provider/backend health
|
|
279
|
+
radiocli version Print the installed version
|
|
280
|
+
radiocli update Check for updates; --install to install
|
|
281
|
+
radiocli media-controls [auto|on|off] Configure Linux desktop media controls
|
|
282
|
+
radiocli mcp <command> Install, inspect, or run the MCP integration
|
|
283
|
+
radiocli agent <command> Scriptable radio controls for local agents
|
|
284
|
+
` : ''}
|
|
285
|
+
Examples:
|
|
286
|
+
radiocli play jazz Find a station and listen
|
|
287
|
+
radiocli play Continue the current or last station
|
|
288
|
+
radiocli play --favorites Listen from your favorites
|
|
289
|
+
|
|
290
|
+
More help:
|
|
291
|
+
radiocli help --all Browse every command by category
|
|
292
|
+
radiocli help play Options and examples for one command
|
|
293
|
+
radiocli completion <shell> Print a Tab completion script
|
|
294
|
+
${all ? '\nListener commands support --json, --plain, --quiet, and --no-input.\nUse radiocli <command> --help for options and examples.\n' : ''}
|
|
295
|
+
`.trimEnd());
|
|
296
|
+
}
|
|
297
|
+
function legacyCommandHelp() {
|
|
298
|
+
return {
|
|
299
|
+
check: 'Usage: radiocli check\nShow local store paths, playback backends, and provider health.',
|
|
300
|
+
doctor: 'Usage: radiocli doctor [--json] [--playback]\nCheck playback tools without changing settings. Add --playback to rehearse local WAV playback through null/dummy audio.',
|
|
301
|
+
update: 'Usage: radiocli update [--install]\nCheck for updates. Add --install to install and repair enabled MCP entries.',
|
|
302
|
+
import: 'Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]\nExample: radiocli import https://example.com/live.mp3 "Example FM"',
|
|
303
|
+
export: 'Usage: radiocli export [file]\nExport favorites and imports as M3U (default: radiocli-favorites.m3u).',
|
|
304
|
+
'media-controls': 'Usage: radiocli media-controls [auto|on|off]\nInspect or configure Linux desktop media controls.',
|
|
305
|
+
mcp: 'Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>\nManage the optional local agent integration.'
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function suggestCommand(input) {
|
|
309
|
+
const candidates = [...listenerCommands, ...Object.keys(legacyCommandHelp()), 'setup', 'alarm', 'agent', 'help', 'version'];
|
|
310
|
+
const distance = (word) => {
|
|
311
|
+
let row = Array.from({ length: word.length + 1 }, (_, index) => index);
|
|
312
|
+
for (let i = 1; i <= input.length; i += 1) {
|
|
313
|
+
const next = [i];
|
|
314
|
+
for (let j = 1; j <= word.length; j += 1)
|
|
315
|
+
next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (input[i - 1] === word[j - 1] ? 0 : 1));
|
|
316
|
+
row = next;
|
|
317
|
+
}
|
|
318
|
+
return row[word.length];
|
|
319
|
+
};
|
|
320
|
+
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
321
|
}
|
|
249
322
|
export function isDirectRun(entryPath, moduleUrl) {
|
|
250
323
|
if (!entryPath) {
|
|
@@ -260,7 +333,8 @@ export function isDirectRun(entryPath, moduleUrl) {
|
|
|
260
333
|
function isKnownCommand(command) {
|
|
261
334
|
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command);
|
|
262
335
|
}
|
|
263
|
-
function printSetupHelp() {
|
|
336
|
+
async function printSetupHelp() {
|
|
337
|
+
const { isAirPlayPlatformSupported } = await import('./player/airplay-policy.js');
|
|
264
338
|
console.log(`RadioCLI Setup
|
|
265
339
|
|
|
266
340
|
Usage:
|
|
@@ -273,100 +347,9 @@ Usage:
|
|
|
273
347
|
radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
|
|
274
348
|
radiocli setup --mcp --headless-agent Opt out of external terminal windows
|
|
275
349
|
radiocli setup --no-mcp Disable and remove agent MCP entries
|
|
276
|
-
radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
350
|
+
${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
351
|
apt, dnf, pacman, apk, zypper,
|
|
278
352
|
pkg (FreeBSD), pkg_add, pkgin,
|
|
279
353
|
termux-pkg (Termux), or pkgman (Haiku)
|
|
280
354
|
`);
|
|
281
355
|
}
|
|
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
|
+
}
|