@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
package/dist/ui/App.js
CHANGED
|
@@ -3,9 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { Box, Text, useApp, useIsScreenReaderEnabled, useStdin, useStdout, useWindowSize } from 'ink';
|
|
5
5
|
import { ProviderManager } from '../providers/provider-manager.js';
|
|
6
|
-
import { PlayerController } from '../player/player-controller.js';
|
|
7
|
-
import {
|
|
6
|
+
import { PlaybackOutputError, PlayerController } from '../player/player-controller.js';
|
|
7
|
+
import { airPlayLocalDeviceLabel, playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
|
|
8
8
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
9
|
+
import { LibraryRepository } from '../storage/repository.js';
|
|
10
|
+
import { RadioEngine } from '../player/radio-engine.js';
|
|
11
|
+
import { TuiPlayer, TuiRadioEngine } from '../agent/tui-session.js';
|
|
9
12
|
import { defaultAgentControlSettings } from '../types.js';
|
|
10
13
|
import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
|
|
11
14
|
import { DisplayContext, resolveDisplayMode } from './display-context.js';
|
|
@@ -27,25 +30,25 @@ import { copyToClipboard, openExternal } from './system-actions.js';
|
|
|
27
30
|
import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
28
31
|
import { appVersion } from '../version.js';
|
|
29
32
|
import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
|
|
30
|
-
import {
|
|
33
|
+
import { airPlayPolicy, airPlaySetupRequiredMessage } from '../player/airplay-policy.js';
|
|
34
|
+
import { helpItemCountForAirPlay } from './help-content.js';
|
|
31
35
|
import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
|
|
32
36
|
import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
|
|
33
37
|
import { useAlarmTui } from './use-alarm-tui.js';
|
|
34
38
|
import { toAsciiSafe } from './ascii.js';
|
|
35
39
|
import { registerTuiPresence } from '../alarms/tui-presence.js';
|
|
36
40
|
import { configureMcpIntegrations } from '../agent/mcp-install.js';
|
|
37
|
-
import { startRadioSession } from '../agent/session.js';
|
|
38
|
-
import {
|
|
41
|
+
import { allowedRadioSessionSettings, resolveDesktopTransport, serializeRadioCommands, startRadioSession } from '../agent/session.js';
|
|
42
|
+
import { desktopMediaPolicy, startDesktopMediaControls } from '../platform/desktop-media.js';
|
|
43
|
+
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
39
44
|
import { ReceiverAnimationProvider } from './receiver-animation.js';
|
|
40
45
|
import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
|
|
41
46
|
const LOADING_SPINNER_MS = 120;
|
|
42
47
|
const TRANSIENT_NOTICE_MS = 4500;
|
|
43
|
-
const LISTENING_HEARTBEAT_MS = 30_000;
|
|
44
48
|
const COUNTRY_STATIONS_PAGE_SIZE = 120;
|
|
45
49
|
const COUNTRY_STATIONS_LOAD_AHEAD = 12;
|
|
46
50
|
const SEARCH_RESULTS_PAGE_SIZE = 90;
|
|
47
51
|
const SEARCH_RESULTS_LOAD_AHEAD = 12;
|
|
48
|
-
const BROKEN_STREAM_AUTO_SKIP_DELAY_MS = 1000;
|
|
49
52
|
const settingToggleLabel = {
|
|
50
53
|
resumeOnLaunch: 'Resume on launch',
|
|
51
54
|
transparentBackground: 'Transparent background',
|
|
@@ -54,37 +57,40 @@ const settingToggleLabel = {
|
|
|
54
57
|
mouseSupport: 'Mouse and trackpad scrolling',
|
|
55
58
|
automaticUpdateChecks: 'Automatic update checks'
|
|
56
59
|
};
|
|
57
|
-
export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform }) {
|
|
60
|
+
export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform, playbackSession, playbackRecoveryWarning }) {
|
|
58
61
|
const { exit } = useApp();
|
|
59
62
|
const { stdin } = useStdin();
|
|
60
63
|
const { stdout } = useStdout();
|
|
61
64
|
const { columns, rows } = useWindowSize();
|
|
62
65
|
const screenReader = useIsScreenReaderEnabled();
|
|
63
|
-
const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
|
|
66
|
+
const store = useMemo(() => new LibraryRepository(providedStore ?? new JsonLibraryStore(), { worker: !providedStore }), [providedStore]);
|
|
64
67
|
const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
|
|
65
68
|
const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
|
|
66
69
|
const installedVersion = useMemo(() => appVersion(), []);
|
|
67
|
-
const
|
|
68
|
-
const [library, setLibrary] = useState(() => store.snapshot());
|
|
70
|
+
const [library, setLibrary] = useState(() => localSessionLibrary(store.snapshot(), platform, playbackSession?.status.tui?.selectionRequired));
|
|
69
71
|
const settingsRef = useRef(library.settings);
|
|
70
|
-
|
|
71
|
-
const player = useMemo(() => new
|
|
72
|
+
const settingsQueueRef = useRef(Promise.resolve());
|
|
73
|
+
const player = useMemo(() => playbackSession ? new TuiPlayer(playbackSession, () => settingsRef.current)
|
|
74
|
+
: new PlayerController(() => settingsRef.current, { platform, arch: process.arch, env: platform === process.platform ? process.env : {} }), [platform, playbackSession]);
|
|
72
75
|
const [playback, setPlayback] = useState(() => player.getState());
|
|
73
76
|
const [availableBackends, setAvailableBackends] = useState(() => player.detectedBackends());
|
|
77
|
+
const airPlay = airPlayPolicy(library.settings, { platform });
|
|
78
|
+
const airPlaySupported = airPlay.enabled;
|
|
79
|
+
const airPlayUnavailableMessage = airPlay.enabled ? (platform === 'linux' ? airPlaySetupRequiredMessage : 'AirPlay is not ready on this install. Run radiocli doctor.') : airPlay.message;
|
|
74
80
|
const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
|
|
75
|
-
const [screen, setScreen] = useState('home');
|
|
81
|
+
const [screen, setScreen] = useState(playbackSession?.status.station ? 'now-playing' : 'home');
|
|
76
82
|
const [selected, setSelected] = useState(0);
|
|
77
83
|
const [settingsPage, setSettingsPage] = useState('root');
|
|
78
84
|
const [message, setMessage] = useState(null);
|
|
79
85
|
const [persistenceWarning, setPersistenceWarning] = useState(null);
|
|
80
|
-
const [presenceWarning, setPresenceWarning] = useState(null);
|
|
86
|
+
const [presenceWarning, setPresenceWarning] = useState(playbackRecoveryWarning ?? null);
|
|
81
87
|
const [footerMessage, setFooterMessage] = useState(null);
|
|
82
88
|
const [countries, setCountries] = useState([]);
|
|
83
89
|
const [countryFilter, setCountryFilter] = useState('');
|
|
84
90
|
const [editingCountryFilter, setEditingCountryFilter] = useState(false);
|
|
85
91
|
const [loadingCountries, setLoadingCountries] = useState(false);
|
|
86
92
|
const [stationContexts, setStationContexts] = useState(initialStationContexts);
|
|
87
|
-
const [
|
|
93
|
+
const [loadingStationContexts, setLoadingStationContexts] = useState({});
|
|
88
94
|
const [searchQuery, setSearchQuery] = useState('');
|
|
89
95
|
const [editingSearch, setEditingSearch] = useState(true);
|
|
90
96
|
const [playingStation, setPlayingStation] = useState(null);
|
|
@@ -98,7 +104,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
98
104
|
const [commandText, setCommandText] = useState('');
|
|
99
105
|
const [airPlayCode, setAirPlayCode] = useState('');
|
|
100
106
|
const [filters, setFilters] = useState({ codec: null, language: null, minBitrate: null });
|
|
101
|
-
const [sleepUntil,
|
|
107
|
+
const [sleepUntil, setSleepUntilState] = useState(playbackSession?.status.tui?.sleepUntil ?? null);
|
|
108
|
+
const setSleepUntil = useCallback((until) => {
|
|
109
|
+
if (playbackSession)
|
|
110
|
+
void playbackSession.call({ type: 'tui-sleep', until }).catch(error => setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not update the sleep timer.'));
|
|
111
|
+
else
|
|
112
|
+
setSleepUntilState(until);
|
|
113
|
+
}, [playbackSession]);
|
|
102
114
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
103
115
|
const [capturingTransportAction, setCapturingTransportAction] = useState(null);
|
|
104
116
|
const reportActionError = useCallback((error) => {
|
|
@@ -106,9 +118,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
106
118
|
}, []);
|
|
107
119
|
// Optional history cannot change the result of playback or navigation. Keep
|
|
108
120
|
// its warning until an explicit save succeeds; a no-op checkpoint proves nothing.
|
|
109
|
-
const persistLibrary = useCallback((write) => {
|
|
121
|
+
const persistLibrary = useCallback(async (write) => {
|
|
110
122
|
try {
|
|
111
|
-
const nextLibrary = write();
|
|
123
|
+
const nextLibrary = localSessionLibrary(await write(), platform, player.airPlaySelectionRequired());
|
|
112
124
|
setLibrary(nextLibrary);
|
|
113
125
|
return nextLibrary;
|
|
114
126
|
}
|
|
@@ -117,18 +129,25 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
117
129
|
setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`);
|
|
118
130
|
return undefined;
|
|
119
131
|
}
|
|
120
|
-
}, []);
|
|
132
|
+
}, [platform, player]);
|
|
133
|
+
const engine = useMemo(() => playbackSession ? new TuiRadioEngine(playbackSession) : new RadioEngine(player, providers, store, () => settingsRef.current, saved => {
|
|
134
|
+
setLibrary(localSessionLibrary(saved, platform, player.airPlaySelectionRequired()));
|
|
135
|
+
}), [player, providers, store, platform, playbackSession]);
|
|
121
136
|
const announcedUpdateRef = useRef(false);
|
|
122
137
|
const automaticUpdateCheckStartedRef = useRef(false);
|
|
123
138
|
const installingUpdateRef = useRef(false);
|
|
124
139
|
const configuringMcpRef = useRef(false);
|
|
125
140
|
const displayStationsRef = useRef([]);
|
|
126
|
-
const playbackQueueRef = useRef(
|
|
141
|
+
const playbackQueueRef = useRef(playbackSession?.status.queue.length
|
|
142
|
+
? { title: 'Recovered station list', sourceScreen: 'now-playing', sourceContextKey: null, stations: playbackSession.status.queue } : null);
|
|
143
|
+
const focusNowPlayingRef = useRef(playbackSession?.status.tui?.focusNowPlaying ?? 0);
|
|
127
144
|
const lastRawTransportAtRef = useRef(0);
|
|
128
|
-
const
|
|
145
|
+
const loadingStationContextsRef = useRef({});
|
|
129
146
|
const countryPageRequestRef = useRef(null);
|
|
130
147
|
const searchPageRequestRef = useRef(null);
|
|
131
|
-
const
|
|
148
|
+
const activeSearchRef = useRef(null);
|
|
149
|
+
const searchContextFingerprintRef = useRef(null);
|
|
150
|
+
const searchFingerprint = JSON.stringify([filters.codec, filters.language, filters.minBitrate, Boolean(library.settings.enableRadioGarden)]);
|
|
132
151
|
const screenRef = useRef(screen);
|
|
133
152
|
const selectedRef = useRef(selected);
|
|
134
153
|
const settingsPageRef = useRef(settingsPage);
|
|
@@ -147,14 +166,33 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
147
166
|
const helpReturnScreenRef = useRef('home');
|
|
148
167
|
const countriesLoadAttemptedRef = useRef(false);
|
|
149
168
|
const tuneRequestRef = useRef(0);
|
|
169
|
+
const outputCancellationRef = useRef(0);
|
|
170
|
+
const pendingOutputChangesRef = useRef(0);
|
|
150
171
|
const searchRequestRef = useRef(0);
|
|
151
172
|
const countryRequestRef = useRef(0);
|
|
152
173
|
const nearbyRequestRef = useRef(0);
|
|
153
|
-
const
|
|
174
|
+
const discoveryAbortRef = useRef({});
|
|
175
|
+
const beginDiscovery = useCallback((key) => {
|
|
176
|
+
discoveryAbortRef.current[key]?.abort();
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
discoveryAbortRef.current[key] = controller;
|
|
179
|
+
return controller.signal;
|
|
180
|
+
}, []);
|
|
181
|
+
const cancelPendingTune = useCallback(() => {
|
|
182
|
+
tuneRequestRef.current += 1;
|
|
183
|
+
outputCancellationRef.current += 1;
|
|
184
|
+
engine.cancel();
|
|
185
|
+
}, [engine]);
|
|
154
186
|
const volumeRequestRef = useRef(0);
|
|
155
187
|
const agentHandlerRef = useRef(async () => {
|
|
156
188
|
throw new Error('RadioCLI is still starting.');
|
|
157
189
|
});
|
|
190
|
+
const dispatchOwnerCommand = useMemo(() => serializeRadioCommands((command, signal) => agentHandlerRef.current(command, signal), { responsive: true }), []);
|
|
191
|
+
const desktopPolicy = useMemo(() => desktopMediaPolicy(library.settings, { platform }), [library.settings.desktopMediaControls, platform]);
|
|
192
|
+
const desktopSuspendedRef = useRef(false);
|
|
193
|
+
const desktopMediaRef = useRef(null);
|
|
194
|
+
const activeAlarmRef = useRef(false);
|
|
195
|
+
const [controlSessionReady, setControlSessionReady] = useState(Boolean(playbackSession));
|
|
158
196
|
const showTransientMessage = useCallback((nextMessage) => {
|
|
159
197
|
if (transientMessageTimerRef.current) {
|
|
160
198
|
clearTimeout(transientMessageTimerRef.current);
|
|
@@ -189,10 +227,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
189
227
|
screenRef.current = screen;
|
|
190
228
|
selectedRef.current = selected;
|
|
191
229
|
settingsPageRef.current = settingsPage;
|
|
192
|
-
loadingStationsRef.current = loadingStations;
|
|
193
230
|
stationContextsRef.current = activeStationContexts;
|
|
194
231
|
exploreCursorRef.current = exploreCursor;
|
|
195
232
|
const renderedStationContextKey = stationContextKeyForScreen(screen);
|
|
233
|
+
const loadingStations = renderedStationContextKey ? Boolean(loadingStationContexts[renderedStationContextKey]) : false;
|
|
196
234
|
const activeStationContextKey = renderedStationContextKey ?? lastStationContextKeyRef.current;
|
|
197
235
|
const stationContext = activeStationContexts[activeStationContextKey];
|
|
198
236
|
const stationCounts = useMemo(() => ({
|
|
@@ -219,8 +257,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
219
257
|
'alarm-ringing': 0,
|
|
220
258
|
'airplay-settings': 0,
|
|
221
259
|
'airplay-code': 1,
|
|
222
|
-
settings: settingsItemsForPage(settingsPage).length,
|
|
223
|
-
help:
|
|
260
|
+
settings: settingsItemsForPage(settingsPage, airPlaySupported).length,
|
|
261
|
+
help: helpItemCountForAirPlay(airPlaySupported)
|
|
224
262
|
});
|
|
225
263
|
itemCountsRef.current = {
|
|
226
264
|
home: homeItems.length,
|
|
@@ -239,8 +277,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
239
277
|
'alarm-ringing': 0,
|
|
240
278
|
'airplay-settings': availableAirPlayDevices.length,
|
|
241
279
|
'airplay-code': 1,
|
|
242
|
-
settings: settingsItemsForPage(settingsPage).length,
|
|
243
|
-
help:
|
|
280
|
+
settings: settingsItemsForPage(settingsPage, airPlaySupported).length,
|
|
281
|
+
help: helpItemCountForAirPlay(airPlaySupported)
|
|
244
282
|
};
|
|
245
283
|
const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
|
|
246
284
|
displayStationsRef.current = displayStations;
|
|
@@ -261,7 +299,6 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
261
299
|
!capturingTransportAction &&
|
|
262
300
|
!editingCountryFilter &&
|
|
263
301
|
shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
|
|
264
|
-
useEffect(() => player.onChange(setPlayback), [player]);
|
|
265
302
|
useEffect(() => {
|
|
266
303
|
if (!stdin.isTTY)
|
|
267
304
|
return;
|
|
@@ -277,49 +314,48 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
277
314
|
};
|
|
278
315
|
}
|
|
279
316
|
catch {
|
|
280
|
-
setPresenceWarning('Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.');
|
|
317
|
+
setPresenceWarning([playbackRecoveryWarning, 'Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.'].filter(Boolean).join(' '));
|
|
281
318
|
}
|
|
282
|
-
}, [stdin]);
|
|
319
|
+
}, [stdin, playbackRecoveryWarning]);
|
|
283
320
|
const playingStationRef = useRef(null);
|
|
284
321
|
playingStationRef.current = playingStation;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
322
|
+
useEffect(() => engine.onChange(current => {
|
|
323
|
+
setPlayback(current.playback);
|
|
324
|
+
playingStationRef.current = current.station;
|
|
325
|
+
setPlayingStation(current.station);
|
|
326
|
+
setTuningStation(current.tuningStation);
|
|
327
|
+
setNowPlaying(current.nowPlaying);
|
|
328
|
+
if (playbackSession) {
|
|
329
|
+
setSleepUntilState(playbackSession.status.tui?.sleepUntil ?? null);
|
|
330
|
+
const previous = playbackQueueRef.current;
|
|
331
|
+
playbackQueueRef.current = current.queue.length ? { ...(previous ?? { title: 'Current station list', sourceScreen: 'now-playing', sourceContextKey: null }), stations: current.queue } : null;
|
|
332
|
+
const focus = playbackSession.status.tui?.focusNowPlaying ?? 0;
|
|
333
|
+
if (focus !== focusNowPlayingRef.current) {
|
|
334
|
+
focusNowPlayingRef.current = focus;
|
|
335
|
+
screenRef.current = 'now-playing';
|
|
336
|
+
setScreen('now-playing');
|
|
295
337
|
}
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
if (activeListeningStationRef.current) {
|
|
299
|
-
activeListeningStationRef.current = null;
|
|
300
|
-
persistLibrary(() => store.finishActiveListeningSession());
|
|
301
338
|
}
|
|
302
|
-
|
|
339
|
+
if (current.warning)
|
|
340
|
+
setPersistenceWarning(current.warning.replace('Listening history was not saved', 'Library not saved'));
|
|
341
|
+
if (current.message)
|
|
342
|
+
setMessage(current.message);
|
|
343
|
+
}), [engine, playbackSession]);
|
|
303
344
|
useEffect(() => {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
return () =>
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return;
|
|
319
|
-
lastRecordedTrackRef.current = trackKey;
|
|
320
|
-
persistLibrary(() => store.recordTrack(station, metadata.title));
|
|
321
|
-
}
|
|
322
|
-
}), [persistLibrary, player, store]);
|
|
345
|
+
const off = store.subscribe(saved => {
|
|
346
|
+
const next = localSessionLibrary(saved, platform, player.airPlaySelectionRequired());
|
|
347
|
+
settingsRef.current = next.settings;
|
|
348
|
+
setLibrary(next);
|
|
349
|
+
});
|
|
350
|
+
const offError = store.onError(error => setPersistenceWarning(`Library could not refresh: ${error.message}`));
|
|
351
|
+
return () => {
|
|
352
|
+
off();
|
|
353
|
+
offError();
|
|
354
|
+
// Normal shutdown closes the owner explicitly. An unexpected render
|
|
355
|
+
// failure must release this view's storage without stopping its audio.
|
|
356
|
+
void (playbackSession ? store.close() : engine.close().finally(() => store.close())).catch(() => undefined);
|
|
357
|
+
};
|
|
358
|
+
}, [store, engine, platform, player, playbackSession]);
|
|
323
359
|
useEffect(() => {
|
|
324
360
|
if (!stdout.isTTY || !mouseReportingActive) {
|
|
325
361
|
return;
|
|
@@ -361,6 +397,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
361
397
|
}
|
|
362
398
|
}, [countries.length, loadingCountries, providers, screen]);
|
|
363
399
|
useEffect(() => () => {
|
|
400
|
+
exploreRequestRef.current += 1;
|
|
401
|
+
countryRequestRef.current += 1;
|
|
402
|
+
searchRequestRef.current += 1;
|
|
403
|
+
nearbyRequestRef.current += 1;
|
|
404
|
+
for (const controller of Object.values(discoveryAbortRef.current))
|
|
405
|
+
controller.abort();
|
|
406
|
+
countryPageRequestRef.current = null;
|
|
407
|
+
searchPageRequestRef.current = null;
|
|
364
408
|
if (exploreMoveTimerRef.current) {
|
|
365
409
|
clearTimeout(exploreMoveTimerRef.current);
|
|
366
410
|
}
|
|
@@ -370,44 +414,41 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
370
414
|
if (transientFooterMessageTimerRef.current) {
|
|
371
415
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
372
416
|
}
|
|
373
|
-
if (skipBrokenTimerRef.current) {
|
|
374
|
-
clearTimeout(skipBrokenTimerRef.current);
|
|
375
|
-
}
|
|
376
417
|
}, []);
|
|
377
418
|
useEffect(() => {
|
|
378
419
|
setSelected(value => clamp(value, currentItemCount(screen) - 1));
|
|
379
420
|
}, [availableAirPlayDevices.length, displayStations.length, filteredCountries.length, screen]);
|
|
380
421
|
useEffect(() => {
|
|
422
|
+
if (playbackSession)
|
|
423
|
+
return;
|
|
381
424
|
if (!sleepUntil) {
|
|
382
425
|
return;
|
|
383
426
|
}
|
|
384
427
|
const delayMs = sleepUntil - Date.now();
|
|
385
428
|
if (delayMs <= 0) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
void player.stop().catch(reportActionError);
|
|
429
|
+
cancelPendingTune();
|
|
430
|
+
void engine.stop({ retainSelection: true }).catch(reportActionError);
|
|
389
431
|
setSleepUntil(null);
|
|
390
432
|
return;
|
|
391
433
|
}
|
|
392
434
|
const timer = setTimeout(() => {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
void player.stop().catch(reportActionError);
|
|
435
|
+
cancelPendingTune();
|
|
436
|
+
void engine.stop({ retainSelection: true }).catch(reportActionError);
|
|
396
437
|
setSleepUntil(null);
|
|
397
438
|
}, delayMs);
|
|
398
439
|
return () => clearTimeout(timer);
|
|
399
|
-
}, [
|
|
440
|
+
}, [cancelPendingTune, engine, reportActionError, sleepUntil, playbackSession, setSleepUntil]);
|
|
400
441
|
useEffect(() => {
|
|
401
442
|
const backends = player.refreshDetectedBackends();
|
|
402
443
|
setAvailableBackends(backends);
|
|
403
444
|
const network = networkPolicy();
|
|
404
|
-
if (airPlaySupported && !network.offline && !network.lowBandwidth) {
|
|
445
|
+
if (platform === 'darwin' && airPlaySupported && !network.offline && !network.lowBandwidth) {
|
|
405
446
|
void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
|
|
406
447
|
}
|
|
407
448
|
if (backends.length === 0) {
|
|
408
449
|
setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
|
|
409
450
|
}
|
|
410
|
-
}, [airPlaySupported, player]);
|
|
451
|
+
}, [airPlaySupported, platform, player]);
|
|
411
452
|
const refreshProviderHealth = useCallback(() => {
|
|
412
453
|
providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({}));
|
|
413
454
|
}, [providers]);
|
|
@@ -443,51 +484,84 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
443
484
|
};
|
|
444
485
|
}, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]);
|
|
445
486
|
const setStationContextFor = useCallback((key, context) => {
|
|
487
|
+
stationContextsRef.current = { ...stationContextsRef.current, [key]: context };
|
|
446
488
|
setStationContexts(current => ({ ...current, [key]: context }));
|
|
447
489
|
}, []);
|
|
490
|
+
const setStationLoading = useCallback((key, loading) => {
|
|
491
|
+
if (loadingStationContextsRef.current[key] === loading)
|
|
492
|
+
return;
|
|
493
|
+
loadingStationContextsRef.current[key] = loading;
|
|
494
|
+
setLoadingStationContexts(current => ({ ...current, [key]: loading }));
|
|
495
|
+
}, []);
|
|
448
496
|
const stationMatches = useCallback((left, right) => stationKey(left) === stationKey(right), []);
|
|
449
497
|
const queueContainsStation = useCallback((queue, station) => Boolean(queue?.stations.some(item => stationMatches(item, station))), [stationMatches]);
|
|
450
|
-
const updateSettings = useCallback((
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
498
|
+
const updateSettings = useCallback((update) => {
|
|
499
|
+
// Evaluate relative inputs after the preceding save has acknowledged. React
|
|
500
|
+
// may still be rendering that save, so the committed ref is authoritative.
|
|
501
|
+
const operation = settingsQueueRef.current.then(async () => {
|
|
502
|
+
const settings = typeof update === 'function' ? update(settingsRef.current) : update;
|
|
503
|
+
const saved = await store.updateSettings(settings);
|
|
504
|
+
if (settings.preferredBackend === 'airplay' && settings.preferredAirPlayDevice) {
|
|
505
|
+
// Receiver consent belongs to this acknowledged selection. Publish its
|
|
506
|
+
// ID to the player before applying the Linux session output overlay.
|
|
507
|
+
settingsRef.current = saved.settings;
|
|
508
|
+
await player.selectAirPlayDevice(settings.preferredAirPlayDevice);
|
|
509
|
+
}
|
|
510
|
+
if (settings.preferredBackend && settings.preferredBackend !== 'airplay')
|
|
511
|
+
await player.selectAirPlayDevice(null);
|
|
512
|
+
const nextLibrary = localSessionLibrary(saved, platform, player.airPlaySelectionRequired());
|
|
513
|
+
if (settings.enableNearbyLocation === false) {
|
|
514
|
+
nearbyRequestRef.current += 1;
|
|
515
|
+
discoveryAbortRef.current.nearby?.abort();
|
|
516
|
+
setStationLoading('nearby', false);
|
|
517
|
+
}
|
|
518
|
+
settingsRef.current = nextLibrary.settings;
|
|
519
|
+
setLibrary(nextLibrary);
|
|
520
|
+
setPersistenceWarning(null);
|
|
521
|
+
engine.clearWarning();
|
|
522
|
+
return nextLibrary;
|
|
523
|
+
});
|
|
524
|
+
settingsQueueRef.current = operation.then(() => undefined, () => undefined);
|
|
525
|
+
// Keyboard callbacks can intentionally fire and forget; awaited callers still
|
|
526
|
+
// receive the original rejection and never acknowledge an uncommitted save.
|
|
527
|
+
void operation.catch(reportActionError);
|
|
528
|
+
return operation;
|
|
529
|
+
}, [engine, platform, player, reportActionError, setStationLoading, store]);
|
|
457
530
|
useEffect(() => {
|
|
458
531
|
if (!selectedAirPlayDevice?.local || settingsRef.current.preferredBackend !== 'airplay') {
|
|
459
532
|
return;
|
|
460
533
|
}
|
|
461
534
|
const preferredBackend = preferredLocalPlaybackBackend(availableBackends);
|
|
462
535
|
if (!preferredBackend) {
|
|
463
|
-
setMessage(`${selectedAirPlayDevice.name} is
|
|
536
|
+
setMessage(`${selectedAirPlayDevice.name} is ${airPlayLocalDeviceLabel(platform)}. Install mpv to use local playback instead of AirPlay.`);
|
|
464
537
|
return;
|
|
465
538
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}, [updateSettings]);
|
|
539
|
+
let changed = false;
|
|
540
|
+
void updateSettings(current => {
|
|
541
|
+
if (current.preferredBackend !== 'airplay' || current.preferredAirPlayDevice !== selectedAirPlayDevice.id)
|
|
542
|
+
return {};
|
|
543
|
+
changed = true;
|
|
544
|
+
return { preferredBackend };
|
|
545
|
+
}).then(() => {
|
|
546
|
+
if (changed)
|
|
547
|
+
setMessage(`${selectedAirPlayDevice.name} is ${airPlayLocalDeviceLabel(platform)}. Audio output: ${audioOutputLabel(preferredBackend)}.`);
|
|
548
|
+
}).catch(() => undefined);
|
|
549
|
+
}, [availableBackends, platform, selectedAirPlayDevice, updateSettings]);
|
|
477
550
|
const beginLearningTransportKey = useCallback((action) => {
|
|
478
551
|
setCapturingTransportAction(action);
|
|
479
552
|
setMessage(`Press a key for ${mediaActionLabel(action)}. Esc cancels.`);
|
|
480
553
|
}, []);
|
|
481
554
|
const resetLearnedTransportKeys = useCallback(() => {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
555
|
+
void updateSettings({ mediaKeys: { previous: [], playPause: [], next: [] } })
|
|
556
|
+
.then(() => setMessage('Learned media keys reset. Built-in fallbacks still work.'))
|
|
557
|
+
.catch(() => undefined);
|
|
558
|
+
}, [updateSettings]);
|
|
485
559
|
const saveLearnedTransportKey = useCallback((action, input) => {
|
|
486
|
-
const mediaKeys = addMediaKeyBinding(settingsRef.current.mediaKeys, action, input);
|
|
487
|
-
updateMediaKeys(mediaKeys);
|
|
488
560
|
setCapturingTransportAction(null);
|
|
489
|
-
|
|
490
|
-
|
|
561
|
+
void updateSettings(current => ({ mediaKeys: addMediaKeyBinding(current.mediaKeys, action, input) }))
|
|
562
|
+
.then(() => setMessage(`Learned ${mediaActionLabel(action)} key.`))
|
|
563
|
+
.catch(() => undefined);
|
|
564
|
+
}, [updateSettings]);
|
|
491
565
|
const go = useCallback((next, options = {}) => {
|
|
492
566
|
if (next === 'help' && screenRef.current !== 'help') {
|
|
493
567
|
helpReturnScreenRef.current = screenRef.current;
|
|
@@ -501,27 +575,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
501
575
|
screenRef.current !== 'airplay-code') {
|
|
502
576
|
settingsPageRef.current = 'root';
|
|
503
577
|
setSettingsPage('root');
|
|
504
|
-
itemCountsRef.current.settings = settingsItemsForPage('root').length;
|
|
578
|
+
itemCountsRef.current.settings = settingsItemsForPage('root', airPlaySupported).length;
|
|
505
579
|
selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0;
|
|
506
580
|
}
|
|
507
581
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
508
582
|
const remembered = selectedByScreenRef.current[destination] ?? 0;
|
|
509
|
-
const nextSelection = options.resetSelection ? 0 : remembered;
|
|
583
|
+
const nextSelection = clamp(options.resetSelection ? 0 : remembered, (itemCountsRef.current[destination] ?? 0) - 1);
|
|
584
|
+
// Input can arrive in one batch before React renders the next screen.
|
|
585
|
+
screenRef.current = destination;
|
|
586
|
+
selectedRef.current = nextSelection;
|
|
510
587
|
setScreen(destination);
|
|
511
|
-
setSelected(
|
|
588
|
+
setSelected(nextSelection);
|
|
512
589
|
if (options.clearMessage !== false) {
|
|
513
590
|
setMessage(null);
|
|
514
591
|
}
|
|
515
|
-
}, []);
|
|
592
|
+
}, [airPlaySupported]);
|
|
516
593
|
const openSettingsPage = useCallback((next) => {
|
|
517
594
|
selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current;
|
|
518
595
|
settingsPageRef.current = next;
|
|
519
596
|
setSettingsPage(next);
|
|
520
|
-
const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next).length - 1);
|
|
597
|
+
const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next, airPlaySupported).length - 1);
|
|
521
598
|
selectedRef.current = nextSelection;
|
|
522
599
|
setSelected(nextSelection);
|
|
523
600
|
setMessage(null);
|
|
524
|
-
}, []);
|
|
601
|
+
}, [airPlaySupported]);
|
|
525
602
|
const closeSettingsPage = useCallback(() => {
|
|
526
603
|
if (settingsPageRef.current === 'root') {
|
|
527
604
|
go('home');
|
|
@@ -531,12 +608,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
531
608
|
}, [go, openSettingsPage]);
|
|
532
609
|
const openAirPlayCode = useCallback(() => {
|
|
533
610
|
if (!airPlaySupported) {
|
|
534
|
-
showTransientMessage(
|
|
611
|
+
showTransientMessage(airPlayUnavailableMessage);
|
|
535
612
|
return;
|
|
536
613
|
}
|
|
537
614
|
setAirPlayCode('');
|
|
538
615
|
go('airplay-code', { resetSelection: true, clearMessage: false });
|
|
539
|
-
}, [airPlaySupported, go, showTransientMessage]);
|
|
616
|
+
}, [airPlaySupported, airPlayUnavailableMessage, go, showTransientMessage]);
|
|
540
617
|
useEffect(() => {
|
|
541
618
|
if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') {
|
|
542
619
|
openAirPlayCode();
|
|
@@ -544,13 +621,18 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
544
621
|
}, [openAirPlayCode, playback.backend, playback.message]);
|
|
545
622
|
const shutdown = useCallback(() => {
|
|
546
623
|
tuneRequestRef.current += 1;
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
624
|
+
desktopSuspendedRef.current = true;
|
|
625
|
+
desktopMediaRef.current?.close();
|
|
626
|
+
cancelPendingTune();
|
|
627
|
+
void engine.close().finally(() => store.close()).catch(reportActionError).finally(exit);
|
|
628
|
+
}, [cancelPendingTune, engine, exit, reportActionError, store]);
|
|
550
629
|
useEffect(() => {
|
|
551
630
|
const finishForSignal = () => {
|
|
552
|
-
|
|
553
|
-
|
|
631
|
+
tuneRequestRef.current += 1;
|
|
632
|
+
desktopSuspendedRef.current = true;
|
|
633
|
+
desktopMediaRef.current?.close();
|
|
634
|
+
cancelPendingTune();
|
|
635
|
+
void engine.close().finally(() => store.close()).catch(reportActionError).finally(() => process.exit(0));
|
|
554
636
|
};
|
|
555
637
|
process.once('SIGTERM', finishForSignal);
|
|
556
638
|
process.once('SIGHUP', finishForSignal);
|
|
@@ -558,19 +640,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
558
640
|
process.off('SIGTERM', finishForSignal);
|
|
559
641
|
process.off('SIGHUP', finishForSignal);
|
|
560
642
|
};
|
|
561
|
-
}, [
|
|
562
|
-
const
|
|
563
|
-
setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
|
|
564
|
-
go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
|
|
565
|
-
}, [go, setStationContextFor]);
|
|
566
|
-
const loadExploreAt = useCallback(async (cursor, options = {}) => {
|
|
643
|
+
}, [cancelPendingTune, engine, reportActionError, store]);
|
|
644
|
+
const loadExploreAt = useCallback(async (cursor) => {
|
|
567
645
|
const requestId = exploreRequestRef.current + 1;
|
|
568
646
|
exploreRequestRef.current = requestId;
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (screenRef.current
|
|
572
|
-
|
|
573
|
-
}
|
|
647
|
+
const signal = beginDiscovery('explore');
|
|
648
|
+
setStationLoading('explore', true);
|
|
649
|
+
if (screenRef.current === 'explore')
|
|
650
|
+
setMessage(null);
|
|
574
651
|
exploreCursorRef.current = cursor;
|
|
575
652
|
setExploreCursor(cursor);
|
|
576
653
|
const previousExploreStations = stationContextsRef.current.explore.stations;
|
|
@@ -581,7 +658,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
581
658
|
error: undefined
|
|
582
659
|
});
|
|
583
660
|
try {
|
|
584
|
-
const stations = await providers.nearby(exploreCursorLocation(cursor), 90);
|
|
661
|
+
const stations = await providers.nearby(exploreCursorLocation(cursor), 90, { signal });
|
|
585
662
|
if (requestId !== exploreRequestRef.current) {
|
|
586
663
|
return;
|
|
587
664
|
}
|
|
@@ -594,7 +671,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
594
671
|
if (screenRef.current === 'explore') {
|
|
595
672
|
setSelected(0);
|
|
596
673
|
}
|
|
597
|
-
if (stations.length === 0) {
|
|
674
|
+
if (stations.length === 0 && screenRef.current === 'explore') {
|
|
598
675
|
setMessage(`No geotagged stations found near ${formatExploreCursor(cursor)}.`);
|
|
599
676
|
}
|
|
600
677
|
}
|
|
@@ -608,23 +685,25 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
608
685
|
subtitle: `World station directory unavailable near ${formatExploreCursor(cursor)}`,
|
|
609
686
|
error: loadError
|
|
610
687
|
});
|
|
611
|
-
|
|
688
|
+
if (screenRef.current === 'explore')
|
|
689
|
+
setMessage(loadError);
|
|
612
690
|
}
|
|
613
691
|
finally {
|
|
614
692
|
if (requestId === exploreRequestRef.current) {
|
|
615
|
-
|
|
693
|
+
setStationLoading('explore', false);
|
|
616
694
|
}
|
|
617
695
|
}
|
|
618
|
-
}, [
|
|
696
|
+
}, [beginDiscovery, providers, setStationContextFor, setStationLoading]);
|
|
619
697
|
const loadExplore = useCallback(async () => {
|
|
620
|
-
await loadExploreAt(exploreCursorRef.current
|
|
698
|
+
await loadExploreAt(exploreCursorRef.current);
|
|
621
699
|
}, [loadExploreAt]);
|
|
622
700
|
const moveExploreMapCursor = useCallback((direction, fast = false) => {
|
|
623
701
|
const next = shiftExploreCursor(exploreCursorRef.current, direction, fast);
|
|
702
|
+
exploreRequestRef.current += 1;
|
|
624
703
|
exploreCursorRef.current = next;
|
|
625
704
|
setExploreCursor(next);
|
|
626
705
|
setSelected(0);
|
|
627
|
-
|
|
706
|
+
setStationLoading('explore', true);
|
|
628
707
|
setStationContextFor('explore', {
|
|
629
708
|
title: 'Explore world',
|
|
630
709
|
subtitle: `Move cursor: ${formatExploreCursor(next)}`,
|
|
@@ -634,9 +713,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
634
713
|
clearTimeout(exploreMoveTimerRef.current);
|
|
635
714
|
}
|
|
636
715
|
exploreMoveTimerRef.current = setTimeout(() => {
|
|
637
|
-
|
|
716
|
+
exploreMoveTimerRef.current = null;
|
|
717
|
+
void loadExploreAt(next);
|
|
638
718
|
}, 220);
|
|
639
|
-
}, [loadExploreAt, setStationContextFor]);
|
|
719
|
+
}, [loadExploreAt, setStationContextFor, setStationLoading]);
|
|
640
720
|
const moveExploreMapCursorToCell = useCallback((x, y) => {
|
|
641
721
|
const next = exploreCursorForMouseCell(x, y, frameWidth, layout);
|
|
642
722
|
if (!next) {
|
|
@@ -644,84 +724,112 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
644
724
|
}
|
|
645
725
|
if (exploreMoveTimerRef.current) {
|
|
646
726
|
clearTimeout(exploreMoveTimerRef.current);
|
|
727
|
+
exploreMoveTimerRef.current = null;
|
|
647
728
|
}
|
|
648
|
-
void loadExploreAt(next
|
|
729
|
+
void loadExploreAt(next);
|
|
649
730
|
}, [frameWidth, layout, loadExploreAt]);
|
|
650
|
-
const loadCountry = useCallback(async (country) => {
|
|
731
|
+
const loadCountry = useCallback(async (country, navigate = true) => {
|
|
732
|
+
const previous = stationContextsRef.current.stations;
|
|
733
|
+
if (previous.country?.code === country.code && loadingStationContextsRef.current.stations && !countryPageRequestRef.current) {
|
|
734
|
+
if (navigate)
|
|
735
|
+
go('stations');
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const signal = beginDiscovery('stations');
|
|
651
739
|
const requestId = countryRequestRef.current + 1;
|
|
652
740
|
countryRequestRef.current = requestId;
|
|
653
|
-
|
|
654
|
-
setMessage(null);
|
|
741
|
+
setStationLoading('stations', true);
|
|
655
742
|
countryPageRequestRef.current = null;
|
|
743
|
+
const retainedStations = previous.country?.code === country.code ? previous.stations : [];
|
|
744
|
+
setStationContextFor('stations', {
|
|
745
|
+
title: country.name,
|
|
746
|
+
subtitle: formatCountryStationsSubtitle(country, retainedStations.length, false),
|
|
747
|
+
stations: retainedStations,
|
|
748
|
+
country
|
|
749
|
+
});
|
|
750
|
+
if (navigate)
|
|
751
|
+
go('stations', { resetSelection: true });
|
|
656
752
|
try {
|
|
657
|
-
const
|
|
753
|
+
const page = typeof providers.byCountryPage === 'function'
|
|
754
|
+
? await providers.byCountryPage(country.code, { limit: COUNTRY_STATIONS_PAGE_SIZE, signal })
|
|
755
|
+
: legacyDiscoveryPage(await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, 0), COUNTRY_STATIONS_PAGE_SIZE);
|
|
756
|
+
const stations = page.items;
|
|
658
757
|
if (requestId !== countryRequestRef.current)
|
|
659
758
|
return;
|
|
660
|
-
|
|
759
|
+
setStationContextFor('stations', {
|
|
661
760
|
title: country.name,
|
|
662
|
-
subtitle: formatCountryStationsSubtitle(country, stations.length,
|
|
761
|
+
subtitle: formatCountryStationsSubtitle(country, stations.length, !page.exhausted),
|
|
663
762
|
stations,
|
|
664
763
|
country,
|
|
665
|
-
hasMore:
|
|
666
|
-
|
|
764
|
+
hasMore: !page.exhausted, nextCursor: page.nextCursor, sources: page.sources,
|
|
765
|
+
error: discoverySourceWarning(page.sources)
|
|
766
|
+
});
|
|
767
|
+
selectedByScreenRef.current.stations = 0;
|
|
768
|
+
if (screenRef.current === 'stations')
|
|
769
|
+
setSelected(0);
|
|
667
770
|
}
|
|
668
771
|
catch (error) {
|
|
669
772
|
if (requestId !== countryRequestRef.current)
|
|
670
773
|
return;
|
|
671
|
-
|
|
774
|
+
const loadError = error instanceof Error ? error.message : `Could not load ${country.name}.`;
|
|
775
|
+
setStationContextFor('stations', { ...stationContextsRef.current.stations, error: loadError });
|
|
776
|
+
if (screenRef.current === 'stations')
|
|
777
|
+
setMessage(loadError);
|
|
672
778
|
}
|
|
673
779
|
finally {
|
|
674
780
|
if (requestId === countryRequestRef.current)
|
|
675
|
-
|
|
781
|
+
setStationLoading('stations', false);
|
|
676
782
|
}
|
|
677
|
-
}, [providers,
|
|
783
|
+
}, [beginDiscovery, go, providers, setStationContextFor, setStationLoading]);
|
|
678
784
|
const loadMoreCountryStations = useCallback(async () => {
|
|
679
785
|
const context = stationContextsRef.current.stations;
|
|
680
786
|
const country = context.country;
|
|
681
|
-
if (!country || !context.hasMore ||
|
|
787
|
+
if (!country || !context.hasMore || loadingStationContextsRef.current.stations) {
|
|
682
788
|
return;
|
|
683
789
|
}
|
|
684
790
|
const offset = context.stations.length;
|
|
685
|
-
const
|
|
791
|
+
const requestId = countryRequestRef.current;
|
|
792
|
+
const requestKey = `${requestId}:${country.code}:${context.nextCursor ?? offset}`;
|
|
686
793
|
if (countryPageRequestRef.current === requestKey) {
|
|
687
794
|
return;
|
|
688
795
|
}
|
|
689
796
|
countryPageRequestRef.current = requestKey;
|
|
690
|
-
|
|
797
|
+
setStationLoading('stations', true);
|
|
691
798
|
try {
|
|
692
|
-
const page =
|
|
799
|
+
const page = typeof providers.byCountryPage === 'function'
|
|
800
|
+
? await providers.byCountryPage(country.code, { limit: COUNTRY_STATIONS_PAGE_SIZE, cursor: context.nextCursor, signal: discoveryAbortRef.current.stations?.signal })
|
|
801
|
+
: legacyDiscoveryPage(await providers.byCountry(country.code, COUNTRY_STATIONS_PAGE_SIZE, offset), COUNTRY_STATIONS_PAGE_SIZE);
|
|
693
802
|
const latest = stationContextsRef.current.stations;
|
|
694
|
-
if (latest.country?.code !== country.code || latest.stations.length !== offset) {
|
|
803
|
+
if (requestId !== countryRequestRef.current || latest.country?.code !== country.code || latest.stations.length !== offset) {
|
|
695
804
|
return;
|
|
696
805
|
}
|
|
697
|
-
const stations = appendUniqueStations(latest.stations, page);
|
|
698
|
-
const hasMore = page.
|
|
699
|
-
stations.length > latest.stations.length &&
|
|
700
|
-
stations.length < country.stationCount;
|
|
806
|
+
const stations = appendUniqueStations(latest.stations, page.items);
|
|
807
|
+
const hasMore = !page.exhausted;
|
|
701
808
|
setStationContextFor('stations', {
|
|
702
809
|
...latest,
|
|
703
810
|
subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
|
|
704
811
|
stations,
|
|
705
|
-
hasMore
|
|
812
|
+
hasMore, nextCursor: page.nextCursor, sources: page.sources,
|
|
813
|
+
error: discoverySourceWarning(page.sources)
|
|
706
814
|
});
|
|
707
815
|
}
|
|
708
816
|
catch (error) {
|
|
709
|
-
if (stationContextsRef.current.stations.country?.code === country.code) {
|
|
817
|
+
if (requestId === countryRequestRef.current && screenRef.current === 'stations' && stationContextsRef.current.stations.country?.code === country.code) {
|
|
710
818
|
setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`);
|
|
711
819
|
}
|
|
712
820
|
}
|
|
713
821
|
finally {
|
|
714
822
|
if (countryPageRequestRef.current === requestKey) {
|
|
715
823
|
countryPageRequestRef.current = null;
|
|
716
|
-
|
|
824
|
+
setStationLoading('stations', false);
|
|
717
825
|
}
|
|
718
826
|
}
|
|
719
|
-
}, [providers, setStationContextFor]);
|
|
827
|
+
}, [providers, setStationContextFor, setStationLoading]);
|
|
720
828
|
useEffect(() => {
|
|
721
829
|
if (screen === 'stations' &&
|
|
722
830
|
stationContexts.stations.country &&
|
|
723
831
|
stationContexts.stations.hasMore &&
|
|
724
|
-
|
|
832
|
+
stationCounts.stations - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
|
|
725
833
|
void loadMoreCountryStations();
|
|
726
834
|
}
|
|
727
835
|
}, [
|
|
@@ -730,101 +838,128 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
730
838
|
selected,
|
|
731
839
|
stationContexts.stations.country,
|
|
732
840
|
stationContexts.stations.hasMore,
|
|
733
|
-
|
|
841
|
+
stationCounts.stations,
|
|
842
|
+
stationContexts.stations.nextCursor
|
|
734
843
|
]);
|
|
735
844
|
const runSearch = useCallback(async (query = searchQuery) => {
|
|
736
845
|
if (!query.trim()) {
|
|
737
846
|
setMessage('Enter a station, genre, language, or place.');
|
|
738
847
|
return;
|
|
739
848
|
}
|
|
849
|
+
const signal = beginDiscovery('search');
|
|
740
850
|
const requestId = searchRequestRef.current + 1;
|
|
741
851
|
searchRequestRef.current = requestId;
|
|
742
|
-
|
|
743
|
-
|
|
852
|
+
activeSearchRef.current = { query: query.trim(), fingerprint: searchFingerprint };
|
|
853
|
+
searchPageRequestRef.current = null;
|
|
854
|
+
setStationLoading('search', true);
|
|
855
|
+
if (screenRef.current === 'search')
|
|
856
|
+
setMessage(null);
|
|
744
857
|
try {
|
|
745
|
-
const
|
|
858
|
+
const pageOptions = {
|
|
746
859
|
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
747
|
-
|
|
860
|
+
signal,
|
|
748
861
|
codec: filters.codec ?? undefined,
|
|
749
862
|
language: filters.language ?? undefined,
|
|
750
863
|
minBitrate: filters.minBitrate ?? undefined
|
|
751
|
-
}
|
|
864
|
+
};
|
|
865
|
+
const page = typeof providers.searchPage === 'function'
|
|
866
|
+
? await providers.searchPage(query, settingsRef.current, pageOptions)
|
|
867
|
+
: legacyDiscoveryPage(await providers.search(query, settingsRef.current, pageOptions), SEARCH_RESULTS_PAGE_SIZE);
|
|
868
|
+
const stations = page.items;
|
|
752
869
|
if (requestId !== searchRequestRef.current)
|
|
753
870
|
return;
|
|
871
|
+
searchContextFingerprintRef.current = searchFingerprint;
|
|
754
872
|
setStationContextFor('search', {
|
|
755
873
|
title: `Search: ${query}`,
|
|
756
|
-
subtitle: formatSearchSubtitle(stations.length,
|
|
874
|
+
subtitle: formatSearchSubtitle(stations.length, !page.exhausted),
|
|
757
875
|
stations,
|
|
758
876
|
query: query.trim(),
|
|
759
|
-
hasMore:
|
|
877
|
+
hasMore: !page.exhausted, nextCursor: page.nextCursor, sources: page.sources,
|
|
878
|
+
error: discoverySourceWarning(page.sources)
|
|
760
879
|
});
|
|
761
880
|
selectedByScreenRef.current.search = 0;
|
|
762
881
|
lastSubmittedSearchRef.current = query.trim();
|
|
763
882
|
persistLibrary(() => store.addSearch(query));
|
|
764
883
|
searchHistoryRef.current = { cursor: -1, draft: '' };
|
|
765
|
-
|
|
766
|
-
|
|
884
|
+
if (screenRef.current === 'search') {
|
|
885
|
+
setSelected(0);
|
|
886
|
+
setEditingSearch(true);
|
|
887
|
+
}
|
|
767
888
|
}
|
|
768
889
|
catch (error) {
|
|
769
890
|
if (requestId !== searchRequestRef.current)
|
|
770
891
|
return;
|
|
771
|
-
|
|
892
|
+
if (screenRef.current === 'search')
|
|
893
|
+
setMessage(error instanceof Error ? error.message : 'Search failed.');
|
|
772
894
|
}
|
|
773
895
|
finally {
|
|
774
896
|
if (requestId === searchRequestRef.current)
|
|
775
|
-
|
|
897
|
+
setStationLoading('search', false);
|
|
776
898
|
}
|
|
777
|
-
}, [filters, persistLibrary, providers, searchQuery, setStationContextFor, store]);
|
|
899
|
+
}, [beginDiscovery, filters, persistLibrary, providers, searchFingerprint, searchQuery, setStationContextFor, setStationLoading, store]);
|
|
900
|
+
useEffect(() => {
|
|
901
|
+
const active = activeSearchRef.current;
|
|
902
|
+
// A cursor belongs to the exact server filters and enabled directories.
|
|
903
|
+
// Reissue the submitted query, including when a previous request is pending.
|
|
904
|
+
if (active && active.fingerprint !== searchFingerprint)
|
|
905
|
+
void runSearch(active.query);
|
|
906
|
+
}, [runSearch, searchFingerprint]);
|
|
778
907
|
const loadMoreSearchResults = useCallback(async () => {
|
|
779
908
|
const context = stationContextsRef.current.search;
|
|
780
909
|
const query = context.query;
|
|
781
|
-
if (!query || !context.hasMore ||
|
|
910
|
+
if (!query || !context.hasMore || loadingStationContextsRef.current.search || searchContextFingerprintRef.current !== searchFingerprint) {
|
|
782
911
|
return;
|
|
783
912
|
}
|
|
784
913
|
const offset = context.stations.length;
|
|
785
|
-
const
|
|
914
|
+
const requestId = searchRequestRef.current;
|
|
915
|
+
const requestKey = `${requestId}:${query}:${context.nextCursor ?? offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
|
|
786
916
|
if (searchPageRequestRef.current === requestKey) {
|
|
787
917
|
return;
|
|
788
918
|
}
|
|
789
919
|
searchPageRequestRef.current = requestKey;
|
|
790
|
-
|
|
920
|
+
setStationLoading('search', true);
|
|
791
921
|
try {
|
|
792
|
-
const
|
|
922
|
+
const pageOptions = {
|
|
793
923
|
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
794
|
-
|
|
924
|
+
cursor: context.nextCursor,
|
|
925
|
+
signal: discoveryAbortRef.current.search?.signal,
|
|
795
926
|
codec: filters.codec ?? undefined,
|
|
796
927
|
language: filters.language ?? undefined,
|
|
797
928
|
minBitrate: filters.minBitrate ?? undefined
|
|
798
|
-
}
|
|
929
|
+
};
|
|
930
|
+
const page = typeof providers.searchPage === 'function'
|
|
931
|
+
? await providers.searchPage(query, settingsRef.current, pageOptions)
|
|
932
|
+
: legacyDiscoveryPage(await providers.search(query, settingsRef.current, { ...pageOptions, offset }), SEARCH_RESULTS_PAGE_SIZE);
|
|
799
933
|
const latest = stationContextsRef.current.search;
|
|
800
|
-
if (latest.query !== query || latest.stations.length !== offset) {
|
|
934
|
+
if (requestId !== searchRequestRef.current || latest.query !== query || latest.stations.length !== offset) {
|
|
801
935
|
return;
|
|
802
936
|
}
|
|
803
|
-
const stations = appendUniqueStations(latest.stations, page);
|
|
804
|
-
const hasMore = page.
|
|
937
|
+
const stations = appendUniqueStations(latest.stations, page.items);
|
|
938
|
+
const hasMore = !page.exhausted;
|
|
805
939
|
setStationContextFor('search', {
|
|
806
940
|
...latest,
|
|
807
941
|
subtitle: formatSearchSubtitle(stations.length, hasMore),
|
|
808
942
|
stations,
|
|
809
|
-
hasMore
|
|
943
|
+
hasMore, nextCursor: page.nextCursor, sources: page.sources,
|
|
944
|
+
error: discoverySourceWarning(page.sources)
|
|
810
945
|
});
|
|
811
946
|
}
|
|
812
947
|
catch (error) {
|
|
813
|
-
if (stationContextsRef.current.search.query === query) {
|
|
948
|
+
if (requestId === searchRequestRef.current && screenRef.current === 'search' && stationContextsRef.current.search.query === query) {
|
|
814
949
|
setMessage(error instanceof Error ? error.message : 'Could not load more search results.');
|
|
815
950
|
}
|
|
816
951
|
}
|
|
817
952
|
finally {
|
|
818
953
|
if (searchPageRequestRef.current === requestKey) {
|
|
819
954
|
searchPageRequestRef.current = null;
|
|
820
|
-
|
|
955
|
+
setStationLoading('search', false);
|
|
821
956
|
}
|
|
822
957
|
}
|
|
823
|
-
}, [filters, providers, setStationContextFor]);
|
|
958
|
+
}, [filters, providers, searchFingerprint, setStationContextFor, setStationLoading]);
|
|
824
959
|
useEffect(() => {
|
|
825
960
|
if (screen === 'search' &&
|
|
826
961
|
stationContexts.search.hasMore &&
|
|
827
|
-
|
|
962
|
+
stationCounts.search - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
|
|
828
963
|
void loadMoreSearchResults();
|
|
829
964
|
}
|
|
830
965
|
}, [
|
|
@@ -832,7 +967,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
832
967
|
screen,
|
|
833
968
|
selected,
|
|
834
969
|
stationContexts.search.hasMore,
|
|
835
|
-
|
|
970
|
+
stationCounts.search,
|
|
971
|
+
stationContexts.search.nextCursor
|
|
836
972
|
]);
|
|
837
973
|
const recallSearchHistory = useCallback((direction) => {
|
|
838
974
|
const history = store.snapshot().searchHistory;
|
|
@@ -857,19 +993,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
857
993
|
}
|
|
858
994
|
}, [searchQuery, store]);
|
|
859
995
|
const loadNearby = useCallback(async () => {
|
|
996
|
+
const signal = beginDiscovery('nearby');
|
|
860
997
|
const requestId = nearbyRequestRef.current + 1;
|
|
861
998
|
nearbyRequestRef.current = requestId;
|
|
862
|
-
|
|
863
|
-
|
|
999
|
+
setStationLoading('nearby', true);
|
|
1000
|
+
if (screenRef.current === 'nearby')
|
|
1001
|
+
setMessage(null);
|
|
864
1002
|
setStationContextFor('nearby', {
|
|
865
1003
|
...stationContextsRef.current.nearby,
|
|
866
1004
|
error: undefined
|
|
867
1005
|
});
|
|
868
|
-
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
869
1006
|
try {
|
|
870
1007
|
if (!settingsRef.current.enableNearbyLocation) {
|
|
871
1008
|
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
872
|
-
|
|
1009
|
+
if (screenRef.current === 'nearby')
|
|
1010
|
+
setMessage('Nearby location lookup is off. Showing the last nearby station list.');
|
|
873
1011
|
return;
|
|
874
1012
|
}
|
|
875
1013
|
setStationContextFor('nearby', {
|
|
@@ -880,13 +1018,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
880
1018
|
});
|
|
881
1019
|
return;
|
|
882
1020
|
}
|
|
883
|
-
const detected = location ?? (await providers.detectLocation());
|
|
1021
|
+
const detected = location ?? (await providers.detectLocation({ signal }));
|
|
884
1022
|
if (requestId !== nearbyRequestRef.current)
|
|
885
1023
|
return;
|
|
886
1024
|
setLocation(detected);
|
|
887
1025
|
if (!detected) {
|
|
888
1026
|
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
889
|
-
|
|
1027
|
+
if (screenRef.current === 'nearby')
|
|
1028
|
+
setMessage('Location detection is unavailable. Showing the last nearby station list.');
|
|
890
1029
|
return;
|
|
891
1030
|
}
|
|
892
1031
|
setStationContextFor('nearby', {
|
|
@@ -897,7 +1036,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
897
1036
|
});
|
|
898
1037
|
return;
|
|
899
1038
|
}
|
|
900
|
-
const stations = await providers.nearby(detected, 90);
|
|
1039
|
+
const stations = await providers.nearby(detected, 90, { signal });
|
|
901
1040
|
if (requestId !== nearbyRequestRef.current)
|
|
902
1041
|
return;
|
|
903
1042
|
setStationContextFor('nearby', {
|
|
@@ -914,17 +1053,20 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
914
1053
|
...stationContextsRef.current.nearby,
|
|
915
1054
|
error: loadError
|
|
916
1055
|
});
|
|
917
|
-
|
|
1056
|
+
if (screenRef.current === 'nearby')
|
|
1057
|
+
setMessage(loadError);
|
|
918
1058
|
}
|
|
919
1059
|
finally {
|
|
920
1060
|
if (requestId === nearbyRequestRef.current)
|
|
921
|
-
|
|
1061
|
+
setStationLoading('nearby', false);
|
|
922
1062
|
}
|
|
923
|
-
}, [
|
|
1063
|
+
}, [beginDiscovery, location, providers, setStationContextFor, setStationLoading]);
|
|
924
1064
|
const queueFromCurrentList = useCallback((station) => {
|
|
925
1065
|
const sourceScreen = screenRef.current;
|
|
926
1066
|
const sourceContextKey = stationContextKeyForScreen(sourceScreen);
|
|
927
|
-
const currentList =
|
|
1067
|
+
const currentList = sourceContextKey
|
|
1068
|
+
? applyStationFilters(stationContextsRef.current[sourceContextKey].stations, filters)
|
|
1069
|
+
: displayStationsRef.current;
|
|
928
1070
|
if (currentList.some(item => stationMatches(item, station))) {
|
|
929
1071
|
return {
|
|
930
1072
|
title: sourceContextKey ? stationContextsRef.current[sourceContextKey].title : 'Current station list',
|
|
@@ -942,7 +1084,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
942
1084
|
sourceContextKey: null,
|
|
943
1085
|
stations: [station]
|
|
944
1086
|
};
|
|
945
|
-
}, [queueContainsStation, stationMatches]);
|
|
1087
|
+
}, [filters, queueContainsStation, stationMatches]);
|
|
946
1088
|
const rememberQueueSelection = useCallback((queue, index) => {
|
|
947
1089
|
if (queue.sourceContextKey) {
|
|
948
1090
|
selectedByScreenRef.current[queue.sourceScreen] = index;
|
|
@@ -953,67 +1095,45 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
953
1095
|
}
|
|
954
1096
|
}, []);
|
|
955
1097
|
const playStation = useCallback(async (station, options = {}) => {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
if (
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1098
|
+
if (options.signal?.aborted)
|
|
1099
|
+
return;
|
|
1100
|
+
if (!options.outputChange)
|
|
1101
|
+
outputCancellationRef.current += 1;
|
|
1102
|
+
const requestId = ++tuneRequestRef.current;
|
|
1103
|
+
const pendingSettings = settingsQueueRef.current;
|
|
962
1104
|
const queue = options.queue ?? queueFromCurrentList(station);
|
|
963
|
-
setTuningStation(station);
|
|
964
|
-
setNowPlaying(null);
|
|
965
1105
|
try {
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
// newer station was selected.
|
|
969
|
-
await player.stop();
|
|
970
|
-
if (requestId !== tuneRequestRef.current)
|
|
971
|
-
return;
|
|
972
|
-
const resolved = await providers.resolve(station);
|
|
973
|
-
if (requestId !== tuneRequestRef.current)
|
|
1106
|
+
await pendingSettings;
|
|
1107
|
+
if (requestId !== tuneRequestRef.current || options.signal?.aborted)
|
|
974
1108
|
return;
|
|
975
|
-
|
|
1109
|
+
desktopSuspendedRef.current = false;
|
|
1110
|
+
playbackQueueRef.current = queue;
|
|
1111
|
+
const tuned = await engine.tune(station, { queue: queue.stations, startPaused: options.startPaused, signal: options.signal });
|
|
976
1112
|
if (requestId !== tuneRequestRef.current)
|
|
977
1113
|
return;
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
setSelected(nextLibraryIndex);
|
|
1114
|
+
const index = queue.stations.findIndex(item => stationMatches(item, tuned));
|
|
1115
|
+
if (index >= 0 && !stationMatches(tuned, station))
|
|
1116
|
+
rememberQueueSelection(queue, index);
|
|
1117
|
+
desktopMediaRef.current?.refresh();
|
|
1118
|
+
if (screenRef.current === 'library') {
|
|
1119
|
+
const nextLibraryStations = applyStationFilters(buildLibraryStations(store.getSnapshot()), filters);
|
|
1120
|
+
const nextIndex = nextLibraryStations.findIndex(item => stationMatches(item, tuned));
|
|
1121
|
+
if (nextIndex >= 0) {
|
|
1122
|
+
selectedByScreenRef.current.library = nextIndex;
|
|
1123
|
+
setSelected(nextIndex);
|
|
989
1124
|
}
|
|
990
1125
|
}
|
|
991
|
-
if (options.openNowPlaying)
|
|
1126
|
+
if (options.openNowPlaying)
|
|
992
1127
|
go('now-playing');
|
|
993
|
-
}
|
|
994
1128
|
setMessage(null);
|
|
1129
|
+
return tuned;
|
|
995
1130
|
}
|
|
996
1131
|
catch (error) {
|
|
997
|
-
if (requestId !== tuneRequestRef.current)
|
|
998
|
-
return;
|
|
999
|
-
const message = error instanceof Error ? error.message : 'Could not tune station.';
|
|
1000
|
-
const currentList = queue.stations;
|
|
1001
|
-
const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
|
|
1002
|
-
const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
|
|
1003
|
-
if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
|
|
1004
|
-
setMessage(`${message} Skipping to ${nextStation.name}.`);
|
|
1005
|
-
setTuningStation(nextStation);
|
|
1006
|
-
rememberQueueSelection(queue, currentIndex + 1);
|
|
1007
|
-
skipBrokenTimerRef.current = setTimeout(() => {
|
|
1008
|
-
skipBrokenTimerRef.current = null;
|
|
1009
|
-
playStationRef.current(nextStation, { ...options, queue });
|
|
1010
|
-
}, BROKEN_STREAM_AUTO_SKIP_DELAY_MS);
|
|
1132
|
+
if (requestId !== tuneRequestRef.current || options.signal?.aborted || error.name === 'AbortError')
|
|
1011
1133
|
return;
|
|
1012
|
-
|
|
1013
|
-
setTuningStation(null);
|
|
1014
|
-
setMessage(message);
|
|
1134
|
+
setMessage(error instanceof Error ? error.message : 'Could not tune station.');
|
|
1015
1135
|
}
|
|
1016
|
-
}, [filters, go,
|
|
1136
|
+
}, [engine, filters, go, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
|
|
1017
1137
|
// Resume the most recent station on launch when opted in, like a radio
|
|
1018
1138
|
// powering back on to its last frequency. Runs once and never auto-navigates.
|
|
1019
1139
|
const didResumeRef = useRef(false);
|
|
@@ -1022,6 +1142,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1022
1142
|
return;
|
|
1023
1143
|
}
|
|
1024
1144
|
didResumeRef.current = true;
|
|
1145
|
+
if (playbackSession?.status.station || playbackSession?.status.tui?.tuningStation)
|
|
1146
|
+
return;
|
|
1025
1147
|
if (!settingsRef.current.resumeOnLaunch) {
|
|
1026
1148
|
return;
|
|
1027
1149
|
}
|
|
@@ -1029,16 +1151,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1029
1151
|
if (last) {
|
|
1030
1152
|
void playStation(last);
|
|
1031
1153
|
}
|
|
1032
|
-
}, [playStation, store]);
|
|
1033
|
-
playStationRef.current = playStation;
|
|
1154
|
+
}, [playStation, store, playbackSession]);
|
|
1034
1155
|
const cancelPendingAutoSkip = useCallback(() => {
|
|
1035
|
-
if (!
|
|
1156
|
+
if (!engine.snapshot().message?.includes('Skipping to'))
|
|
1036
1157
|
return;
|
|
1037
|
-
|
|
1038
|
-
skipBrokenTimerRef.current = null;
|
|
1039
|
-
setTuningStation(null);
|
|
1158
|
+
cancelPendingTune();
|
|
1040
1159
|
setMessage('Auto-skip canceled. Choose a station and press Enter.');
|
|
1041
|
-
}, []);
|
|
1160
|
+
}, [cancelPendingTune, engine]);
|
|
1042
1161
|
const showTransientFooterMessage = useCallback((nextMessage, durationMs = TRANSIENT_NOTICE_MS) => {
|
|
1043
1162
|
if (transientFooterMessageTimerRef.current) {
|
|
1044
1163
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
@@ -1059,25 +1178,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1059
1178
|
showTransientFooterMessage('Ctrl+C again to exit', EXIT_CONFIRMATION_MS);
|
|
1060
1179
|
}, [showTransientFooterMessage, shutdown]);
|
|
1061
1180
|
const toggleFavorite = useCallback((station) => {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1181
|
+
const operation = (async () => {
|
|
1182
|
+
if (!station) {
|
|
1183
|
+
setMessage('Select or play a station before pressing f.');
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
const wasFavorite = store.isFavorite(station);
|
|
1187
|
+
setLibrary(localSessionLibrary(await store.toggleFavorite(station), platform, player.airPlaySelectionRequired()));
|
|
1188
|
+
setPersistenceWarning(null);
|
|
1189
|
+
engine.clearWarning();
|
|
1190
|
+
const favoriteMessage = `${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`;
|
|
1191
|
+
if (screenRef.current === 'library' || screenRef.current === 'search') {
|
|
1192
|
+
showTransientFooterMessage(favoriteMessage);
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
showTransientMessage(favoriteMessage);
|
|
1196
|
+
}
|
|
1197
|
+
if (!wasFavorite && settingsRef.current.shareDirectoryVotes) {
|
|
1198
|
+
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
1199
|
+
void providers.vote(station).catch(reportActionError);
|
|
1200
|
+
}
|
|
1201
|
+
})();
|
|
1202
|
+
void operation.catch(reportActionError);
|
|
1203
|
+
return operation;
|
|
1204
|
+
}, [engine, platform, player, providers, reportActionError, showTransientFooterMessage, showTransientMessage, store]);
|
|
1081
1205
|
const showControlResult = useCallback((result) => {
|
|
1082
1206
|
if (!result.ok && result.message) {
|
|
1083
1207
|
setMessage(result.message);
|
|
@@ -1114,16 +1238,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1114
1238
|
const copied = await copyToClipboard(safeUrl);
|
|
1115
1239
|
setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`);
|
|
1116
1240
|
}, [providers]);
|
|
1117
|
-
const submitAirPlayCode = useCallback((code) => {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1241
|
+
const submitAirPlayCode = useCallback(async (code) => {
|
|
1242
|
+
try {
|
|
1243
|
+
const result = await player.submitAirPlayPasscode(code);
|
|
1244
|
+
if (result.ok) {
|
|
1245
|
+
setAirPlayCode('');
|
|
1246
|
+
setMessage(result.message ?? 'AirPlay code sent.');
|
|
1247
|
+
go('now-playing', { clearMessage: false });
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
showControlResult(result);
|
|
1124
1251
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1252
|
+
catch (error) {
|
|
1253
|
+
reportActionError(error);
|
|
1254
|
+
}
|
|
1255
|
+
}, [go, player, showControlResult, reportActionError]);
|
|
1127
1256
|
const setVolume = useCallback((volume) => {
|
|
1128
1257
|
const clamped = clampVolume(volume);
|
|
1129
1258
|
const requestId = volumeRequestRef.current + 1;
|
|
@@ -1152,48 +1281,53 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1152
1281
|
void player.togglePause().then(showControlResult).catch(reportActionError);
|
|
1153
1282
|
}, [player, reportActionError, showControlResult]);
|
|
1154
1283
|
const cycleDisplayColor = useCallback(() => {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
showTransientMessage(`Display color: ${theme}`);
|
|
1284
|
+
void updateSettings(current => ({ theme: nextTheme(current.theme) }))
|
|
1285
|
+
.then(saved => showTransientMessage(`Display color: ${saved.settings.theme}`)).catch(() => undefined);
|
|
1158
1286
|
}, [showTransientMessage, updateSettings]);
|
|
1159
1287
|
const cycleReceiverStyle = useCallback(() => {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
showTransientMessage(`Receiver style: ${receiverStyle}`);
|
|
1288
|
+
void updateSettings(current => ({ receiverStyle: nextReceiverStyle(current.receiverStyle) }))
|
|
1289
|
+
.then(saved => showTransientMessage(`Receiver style: ${saved.settings.receiverStyle}`)).catch(() => undefined);
|
|
1163
1290
|
}, [showTransientMessage, updateSettings]);
|
|
1164
1291
|
const toggleRadioGarden = useCallback(() => {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1292
|
+
void (async () => {
|
|
1293
|
+
const { settings: { enableRadioGarden } } = await updateSettings(current => ({ enableRadioGarden: !current.enableRadioGarden }));
|
|
1294
|
+
setMessage(`Radio Garden ${enableRadioGarden ? 'enabled' : 'disabled'}.`);
|
|
1295
|
+
setTimeout(refreshProviderHealth, 0);
|
|
1296
|
+
})().catch(reportActionError);
|
|
1297
|
+
}, [refreshProviderHealth, reportActionError, updateSettings]);
|
|
1170
1298
|
const toggleNearbyLocation = useCallback(() => {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1299
|
+
void (async () => {
|
|
1300
|
+
const { settings: { enableNearbyLocation } } = await updateSettings(current => ({ enableNearbyLocation: !current.enableNearbyLocation }));
|
|
1301
|
+
setMessage(`Nearby location lookup ${enableNearbyLocation ? 'enabled' : 'disabled'}.`);
|
|
1302
|
+
if (enableNearbyLocation && screenRef.current === 'nearby') {
|
|
1303
|
+
setTimeout(() => void loadNearby(), 0);
|
|
1304
|
+
}
|
|
1305
|
+
else if (!enableNearbyLocation && screenRef.current === 'nearby') {
|
|
1306
|
+
const current = stationContextsRef.current.nearby;
|
|
1307
|
+
setStationContextFor('nearby', {
|
|
1308
|
+
...current,
|
|
1309
|
+
subtitle: 'Location lookup off · showing the last loaded results',
|
|
1310
|
+
error: undefined
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
})().catch(reportActionError);
|
|
1314
|
+
}, [loadNearby, reportActionError, setStationContextFor, updateSettings]);
|
|
1186
1315
|
const toggleDirectoryVoting = useCallback(() => {
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1316
|
+
void (async () => {
|
|
1317
|
+
const { settings: { shareDirectoryVotes } } = await updateSettings(current => ({ shareDirectoryVotes: !current.shareDirectoryVotes }));
|
|
1318
|
+
setMessage(`Radio Browser favorite votes ${shareDirectoryVotes ? 'enabled' : 'disabled'}.`);
|
|
1319
|
+
})().catch(reportActionError);
|
|
1320
|
+
}, [reportActionError, updateSettings]);
|
|
1191
1321
|
const refreshAirPlayTargets = useCallback(async (announce = true) => {
|
|
1192
1322
|
if (!airPlaySupported) {
|
|
1193
1323
|
if (announce)
|
|
1194
|
-
showTransientMessage(
|
|
1324
|
+
showTransientMessage(airPlayUnavailableMessage);
|
|
1195
1325
|
return [];
|
|
1196
1326
|
}
|
|
1327
|
+
if (platform === 'linux' && !player.refreshDetectedBackends().includes('airplay')) {
|
|
1328
|
+
setMessage(airPlaySetupRequiredMessage);
|
|
1329
|
+
throw new PlaybackOutputError(airPlaySetupRequiredMessage);
|
|
1330
|
+
}
|
|
1197
1331
|
if (networkPolicy().offline) {
|
|
1198
1332
|
setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.');
|
|
1199
1333
|
return [];
|
|
@@ -1213,102 +1347,113 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1213
1347
|
}
|
|
1214
1348
|
return devices;
|
|
1215
1349
|
}
|
|
1216
|
-
catch {
|
|
1350
|
+
catch (error) {
|
|
1217
1351
|
setAvailableAirPlayDevices([]);
|
|
1218
|
-
|
|
1219
|
-
setMessage('AirPlay receiver refresh failed.');
|
|
1220
|
-
}
|
|
1221
|
-
return [];
|
|
1352
|
+
throw error instanceof Error ? error : new Error('AirPlay receiver refresh failed.');
|
|
1222
1353
|
}
|
|
1223
|
-
}, [airPlaySupported, player, showTransientMessage]);
|
|
1354
|
+
}, [airPlaySupported, airPlayUnavailableMessage, platform, player, showTransientMessage]);
|
|
1224
1355
|
const openAirPlaySettings = useCallback(() => {
|
|
1225
1356
|
if (!airPlaySupported) {
|
|
1226
|
-
showTransientMessage(
|
|
1357
|
+
showTransientMessage(airPlayUnavailableMessage);
|
|
1227
1358
|
return;
|
|
1228
1359
|
}
|
|
1229
1360
|
const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
|
|
1230
1361
|
selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex);
|
|
1231
1362
|
go('airplay-settings', { resetSelection: false });
|
|
1232
|
-
void refreshAirPlayTargets(false);
|
|
1233
|
-
}, [airPlaySupported, availableAirPlayDevices, go, refreshAirPlayTargets, showTransientMessage]);
|
|
1363
|
+
void refreshAirPlayTargets(false).catch(reportActionError);
|
|
1364
|
+
}, [airPlaySupported, airPlayUnavailableMessage, availableAirPlayDevices, go, refreshAirPlayTargets, reportActionError, showTransientMessage]);
|
|
1234
1365
|
const selectAirPlayDeviceAt = useCallback((index) => {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
const preferredBackend =
|
|
1366
|
+
pendingOutputChangesRef.current += 1;
|
|
1367
|
+
void (async () => {
|
|
1368
|
+
const requestId = outputCancellationRef.current;
|
|
1369
|
+
const device = availableAirPlayDevices[index];
|
|
1370
|
+
if (!device) {
|
|
1371
|
+
setMessage('No AirPlay receiver selected.');
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const preferredBackend = device.local
|
|
1375
|
+
? preferredLocalPlaybackBackend(availableBackends)
|
|
1376
|
+
: isAirPlayBackendAvailable(availableBackends) ? 'airplay' : null;
|
|
1377
|
+
await updateSettings({ preferredAirPlayDevice: device.id, ...(preferredBackend ? { preferredBackend } : {}) });
|
|
1378
|
+
if (requestId !== outputCancellationRef.current)
|
|
1379
|
+
return;
|
|
1380
|
+
selectedByScreenRef.current['airplay-settings'] = index;
|
|
1381
|
+
if (device.local) {
|
|
1382
|
+
if (!preferredBackend) {
|
|
1383
|
+
setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Install mpv to use local playback instead of AirPlay.`);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
|
|
1387
|
+
const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
|
|
1388
|
+
setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Switching audio to ${audioOutputLabel(preferredBackend)}...`);
|
|
1389
|
+
await playStation(playingStation, { queue, outputChange: true });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
setMessage(`${device.name} is ${airPlayLocalDeviceLabel(platform)}. Audio output: ${audioOutputLabel(preferredBackend)}.`);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1244
1395
|
if (!preferredBackend) {
|
|
1245
|
-
setMessage(
|
|
1396
|
+
setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`);
|
|
1246
1397
|
return;
|
|
1247
1398
|
}
|
|
1248
|
-
updateSettings({ preferredBackend });
|
|
1249
1399
|
if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
|
|
1250
1400
|
const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
|
|
1251
|
-
setMessage(
|
|
1252
|
-
|
|
1401
|
+
setMessage(`Switching audio to AirPlay: ${device.name}...`);
|
|
1402
|
+
await playStation(playingStation, { queue, outputChange: true });
|
|
1253
1403
|
return;
|
|
1254
1404
|
}
|
|
1255
|
-
setMessage(
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
if (!isAirPlayBackendAvailable(availableBackends)) {
|
|
1259
|
-
setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`);
|
|
1260
|
-
return;
|
|
1261
|
-
}
|
|
1262
|
-
updateSettings({ preferredBackend: 'airplay' });
|
|
1263
|
-
if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
|
|
1264
|
-
const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
|
|
1265
|
-
setMessage(`Switching audio to AirPlay: ${device.name}...`);
|
|
1266
|
-
void playStation(playingStation, { queue });
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
setMessage(`AirPlay receiver: ${device.name}. Audio output: AirPlay.`);
|
|
1270
|
-
}, [availableAirPlayDevices, availableBackends, playback.state, playStation, playingStation, queueFromCurrentList, updateSettings]);
|
|
1405
|
+
setMessage(`AirPlay receiver: ${device.name}. Audio output: AirPlay.`);
|
|
1406
|
+
})().catch(reportActionError).finally(() => { pendingOutputChangesRef.current -= 1; });
|
|
1407
|
+
}, [availableAirPlayDevices, availableBackends, platform, playback.state, player, playStation, playingStation, queueFromCurrentList, reportActionError, updateSettings]);
|
|
1271
1408
|
const cycleAudioOutput = useCallback(() => {
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1409
|
+
const precedingOutputChange = pendingOutputChangesRef.current > 0;
|
|
1410
|
+
pendingOutputChangesRef.current += 1;
|
|
1411
|
+
void (async () => {
|
|
1412
|
+
const requestId = outputCancellationRef.current;
|
|
1413
|
+
let preferredBackend;
|
|
1414
|
+
await updateSettings(current => {
|
|
1415
|
+
const currentPlayback = player.getState();
|
|
1416
|
+
const activeNeedsSelectedOutput = Boolean(!precedingOutputChange && playingStation &&
|
|
1417
|
+
shouldRetuneForAudioOutput(currentPlayback.state) &&
|
|
1418
|
+
audioOutputCanApply(current.preferredBackend, current) &&
|
|
1419
|
+
audioOutputNeedsActiveSwitch(current.preferredBackend, currentPlayback.backend, availableBackends));
|
|
1420
|
+
preferredBackend = activeNeedsSelectedOutput
|
|
1421
|
+
? current.preferredBackend
|
|
1422
|
+
: nextAvailablePlaybackBackend(current.preferredBackend, platform === 'linux' && airPlaySupported ? [...new Set([...availableBackends, 'airplay'])] : availableBackends);
|
|
1423
|
+
return { preferredBackend };
|
|
1424
|
+
});
|
|
1425
|
+
if (requestId !== outputCancellationRef.current)
|
|
1426
|
+
return;
|
|
1427
|
+
if (preferredBackend === 'airplay' && (!settingsRef.current.preferredAirPlayDevice || player.airPlaySelectionRequired())) {
|
|
1428
|
+
setMessage('Choose an AirPlay receiver first. Your current station will keep playing until you pick one.');
|
|
1429
|
+
openAirPlaySettings();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
|
|
1433
|
+
const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
|
|
1434
|
+
setMessage(`Switching audio to ${audioOutputSwitchLabel(preferredBackend, availableBackends)}...`);
|
|
1435
|
+
await playStation(playingStation, { queue, outputChange: true });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
setMessage(`Audio output: ${audioOutputSwitchLabel(preferredBackend, availableBackends)}.`);
|
|
1439
|
+
})().catch(reportActionError).finally(() => { pendingOutputChangesRef.current -= 1; });
|
|
1440
|
+
}, [airPlaySupported, availableBackends, openAirPlaySettings, platform, playback.state, player, playStation, playingStation, queueFromCurrentList, reportActionError, updateSettings]);
|
|
1294
1441
|
const toggleSkipBrokenStreams = useCallback(() => {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1442
|
+
void updateSettings(current => ({ skipBrokenStreams: !current.skipBrokenStreams }))
|
|
1443
|
+
.then(saved => setMessage(`Skip broken streams ${saved.settings.skipBrokenStreams ? 'enabled' : 'disabled'}.`))
|
|
1444
|
+
.catch(() => undefined);
|
|
1298
1445
|
}, [updateSettings]);
|
|
1299
1446
|
const toggleSetting = useCallback((key) => {
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
const next = !current;
|
|
1304
|
-
updateSettings({ [key]: next });
|
|
1305
|
-
setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
|
|
1447
|
+
void updateSettings(current => ({ [key]: key === 'mouseSupport' ? current.mouseSupport === false : !current[key] }))
|
|
1448
|
+
.then(saved => setMessage(`${settingToggleLabel[key]} ${saved.settings[key] ? 'on' : 'off'}.`))
|
|
1449
|
+
.catch(() => undefined);
|
|
1306
1450
|
}, [updateSettings]);
|
|
1307
1451
|
const toggleAgentSetting = useCallback((key) => {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${
|
|
1452
|
+
void updateSettings(current => {
|
|
1453
|
+
const agentControl = current.agentControl ?? defaultAgentControlSettings;
|
|
1454
|
+
return { agentControl: { ...agentControl, [key]: !agentControl[key] } };
|
|
1455
|
+
}).then(saved => setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${saved.settings.agentControl?.[key] ? 'on' : 'off'}.`))
|
|
1456
|
+
.catch(() => undefined);
|
|
1312
1457
|
}, [updateSettings]);
|
|
1313
1458
|
const setAgentIntegrationEnabled = useCallback(async () => {
|
|
1314
1459
|
if (configuringMcpRef.current) {
|
|
@@ -1316,19 +1461,18 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1316
1461
|
return;
|
|
1317
1462
|
}
|
|
1318
1463
|
configuringMcpRef.current = true;
|
|
1464
|
+
await settingsQueueRef.current;
|
|
1319
1465
|
const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled;
|
|
1320
1466
|
setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...');
|
|
1321
1467
|
try {
|
|
1322
1468
|
const results = await mcpConfigurator(enabled, mcpRuntime(), null);
|
|
1323
1469
|
const failed = results.filter(item => item.status === 'failed');
|
|
1324
|
-
|
|
1325
|
-
updateSettings({ agentControl: { ...agentControl, enabled } });
|
|
1470
|
+
await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled } }));
|
|
1326
1471
|
setMessage(integrationResultMessage(enabled, results, failed));
|
|
1327
1472
|
}
|
|
1328
1473
|
catch (error) {
|
|
1329
1474
|
if (!enabled) {
|
|
1330
|
-
|
|
1331
|
-
updateSettings({ agentControl: { ...agentControl, enabled: false } });
|
|
1475
|
+
await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled: false } }));
|
|
1332
1476
|
}
|
|
1333
1477
|
setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1334
1478
|
}
|
|
@@ -1346,8 +1490,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1346
1490
|
try {
|
|
1347
1491
|
const results = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1348
1492
|
const failed = results.filter(item => item.status === 'failed');
|
|
1349
|
-
|
|
1350
|
-
updateSettings({ agentControl: { ...agentControl, enabled: true } });
|
|
1493
|
+
await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled: true } }));
|
|
1351
1494
|
setMessage(integrationResultMessage(true, results, failed));
|
|
1352
1495
|
}
|
|
1353
1496
|
catch (error) {
|
|
@@ -1361,42 +1504,44 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1361
1504
|
const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
|
|
1362
1505
|
const next = nextSleepTimerMinutes(currentMinutes);
|
|
1363
1506
|
setSleepUntil(next ? Date.now() + next * 60_000 : null);
|
|
1364
|
-
}, [sleepUntil]);
|
|
1507
|
+
}, [sleepUntil, setSleepUntil]);
|
|
1365
1508
|
const openLibrary = useCallback(() => {
|
|
1366
1509
|
go('library', { resetSelection: false });
|
|
1367
1510
|
}, [go]);
|
|
1368
1511
|
const selectedStation = displayStations[selected] ?? null;
|
|
1369
|
-
const selectedStationForInput = useCallback(() =>
|
|
1512
|
+
const selectedStationForInput = useCallback(() => {
|
|
1513
|
+
const key = stationContextKeyForScreen(screenRef.current);
|
|
1514
|
+
const stations = key ? applyStationFilters(stationContextsRef.current[key].stations, filters) : displayStationsRef.current;
|
|
1515
|
+
return stations[selectedRef.current] ?? null;
|
|
1516
|
+
}, [filters]);
|
|
1370
1517
|
const alarmTui = useAlarmTui({
|
|
1371
1518
|
service: alarmService,
|
|
1372
1519
|
store,
|
|
1373
1520
|
library,
|
|
1374
1521
|
setLibrary,
|
|
1375
1522
|
screen,
|
|
1523
|
+
screenRef,
|
|
1376
1524
|
selected,
|
|
1525
|
+
selectedRef,
|
|
1377
1526
|
setSelected,
|
|
1378
1527
|
go,
|
|
1379
1528
|
setMessage,
|
|
1380
1529
|
playingStation,
|
|
1381
|
-
previewStation: alarmPreview ?? (station => playStation(station))
|
|
1530
|
+
previewStation: alarmPreview ?? (async (station) => { await playStation(station); })
|
|
1382
1531
|
});
|
|
1383
1532
|
for (const alarmScreen of ['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']) {
|
|
1384
1533
|
itemCountsRef.current[alarmScreen] = alarmTui.itemCount(alarmScreen) ?? 0;
|
|
1385
1534
|
}
|
|
1386
1535
|
const openScreen = useCallback((target) => {
|
|
1387
1536
|
if (target === 'explore') {
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
}
|
|
1391
|
-
else {
|
|
1537
|
+
go('explore');
|
|
1538
|
+
if (stationContextsRef.current.explore.stations.length === 0 && !loadingStationContextsRef.current.explore) {
|
|
1392
1539
|
void loadExplore();
|
|
1393
1540
|
}
|
|
1394
1541
|
}
|
|
1395
1542
|
else if (target === 'nearby') {
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
}
|
|
1399
|
-
else {
|
|
1543
|
+
go('nearby');
|
|
1544
|
+
if (stationContextsRef.current.nearby.stations.length === 0 && !loadingStationContextsRef.current.nearby) {
|
|
1400
1545
|
void loadNearby();
|
|
1401
1546
|
}
|
|
1402
1547
|
}
|
|
@@ -1415,14 +1560,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1415
1560
|
}
|
|
1416
1561
|
}, [go, loadExplore, loadNearby, openAirPlaySettings, openLibrary]);
|
|
1417
1562
|
const openAdjacentTab = useCallback((direction) => {
|
|
1418
|
-
const active = activeTabForScreen(
|
|
1563
|
+
const active = activeTabForScreen(screenRef.current);
|
|
1419
1564
|
const currentIndex = topTabs.findIndex(tab => tab.screen === active);
|
|
1420
1565
|
const nextIndex = (currentIndex + direction + topTabs.length) % topTabs.length;
|
|
1421
1566
|
const next = topTabs[nextIndex];
|
|
1422
1567
|
if (next) {
|
|
1423
1568
|
openScreen(next.screen);
|
|
1424
1569
|
}
|
|
1425
|
-
}, [openScreen
|
|
1570
|
+
}, [openScreen]);
|
|
1426
1571
|
const handleUpdateCommand = useCallback(async () => {
|
|
1427
1572
|
const updateCheck = library.updateCheck;
|
|
1428
1573
|
if (!updateCheck || shouldCheckForUpdate(updateCheck)) {
|
|
@@ -1471,14 +1616,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1471
1616
|
try {
|
|
1472
1617
|
const repaired = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1473
1618
|
const suffix = repaired.some(item => item.status === 'failed') ? ' Some MCP clients need manual repair.' : ' MCP integrations repaired.';
|
|
1474
|
-
setMessage(`Update installed.${suffix}
|
|
1619
|
+
setMessage(`Update installed.${suffix} Quit and reopen RadioCLI; restart MCP clients to load updated tools.`);
|
|
1475
1620
|
}
|
|
1476
1621
|
catch {
|
|
1477
|
-
setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then
|
|
1622
|
+
setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then quit and reopen RadioCLI and restart MCP clients.');
|
|
1478
1623
|
}
|
|
1479
1624
|
}
|
|
1480
1625
|
else {
|
|
1481
|
-
setMessage('Update installed.
|
|
1626
|
+
setMessage('Update installed. Quit and reopen RadioCLI to use the new version.');
|
|
1482
1627
|
}
|
|
1483
1628
|
return;
|
|
1484
1629
|
}
|
|
@@ -1487,10 +1632,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1487
1632
|
}, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
|
|
1488
1633
|
const executeCommand = useCommandExecutor({
|
|
1489
1634
|
beginLearningTransportKey,
|
|
1635
|
+
cancelPendingTune,
|
|
1490
1636
|
countries,
|
|
1491
1637
|
go,
|
|
1492
1638
|
loadCountry,
|
|
1493
1639
|
openAirPlaySettings,
|
|
1640
|
+
openAirPlayCode,
|
|
1494
1641
|
openLibrary,
|
|
1495
1642
|
persistLibrary,
|
|
1496
1643
|
player,
|
|
@@ -1499,6 +1646,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1499
1646
|
resetLearnedTransportKeys,
|
|
1500
1647
|
runSearch,
|
|
1501
1648
|
screen,
|
|
1649
|
+
screenRef,
|
|
1502
1650
|
selectedStation,
|
|
1503
1651
|
setCountries,
|
|
1504
1652
|
setFilters,
|
|
@@ -1537,11 +1685,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1537
1685
|
void playStation(nextStation, { queue });
|
|
1538
1686
|
}
|
|
1539
1687
|
}, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
|
|
1540
|
-
agentHandlerRef.current = async (command) => {
|
|
1688
|
+
agentHandlerRef.current = async (command, signal) => {
|
|
1541
1689
|
const sessionStatus = () => ({
|
|
1542
1690
|
owner: 'tui',
|
|
1543
1691
|
playback: player.getState(),
|
|
1544
1692
|
station: playingStationRef.current,
|
|
1693
|
+
nowPlaying: engine.snapshot().nowPlaying,
|
|
1694
|
+
persistenceWarning: persistenceWarning ?? engine.snapshot().warning,
|
|
1545
1695
|
queue: playbackQueueRef.current?.stations ?? [],
|
|
1546
1696
|
output: {
|
|
1547
1697
|
preferredBackend: settingsRef.current.preferredBackend,
|
|
@@ -1549,8 +1699,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1549
1699
|
}
|
|
1550
1700
|
});
|
|
1551
1701
|
const respond = (message, ok = true, data) => ({ ok, message, status: sessionStatus(), ...(data ? { data } : {}) });
|
|
1702
|
+
if (command.type === 'desktop-transport') {
|
|
1703
|
+
const resolved = resolveDesktopTransport(command.action, sessionStatus());
|
|
1704
|
+
return resolved ? agentHandlerRef.current(resolved, signal) : respond('Playback state unchanged.');
|
|
1705
|
+
}
|
|
1552
1706
|
if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !airPlaySupported) {
|
|
1553
|
-
return respond(
|
|
1707
|
+
return respond(airPlayUnavailableMessage, false, command.type === 'airplay-list' ? [] : undefined);
|
|
1554
1708
|
}
|
|
1555
1709
|
if (command.type === 'status')
|
|
1556
1710
|
return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
|
|
@@ -1559,28 +1713,37 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1559
1713
|
return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`);
|
|
1560
1714
|
}
|
|
1561
1715
|
const queue = { title: 'Agent selection', sourceScreen: 'now-playing', sourceContextKey: null, stations: command.queue?.length ? command.queue : [command.station] };
|
|
1562
|
-
await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying });
|
|
1716
|
+
const tuned = await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying, signal });
|
|
1563
1717
|
const state = player.getState();
|
|
1564
|
-
const playingRequestedStation = playingStationRef.current
|
|
1565
|
-
&& stationKey(playingStationRef.current) === stationKey(
|
|
1718
|
+
const playingRequestedStation = tuned && playingStationRef.current
|
|
1719
|
+
&& stationKey(playingStationRef.current) === stationKey(tuned)
|
|
1566
1720
|
&& state.state === 'playing'
|
|
1567
1721
|
&& state.ready;
|
|
1568
1722
|
return playingRequestedStation
|
|
1569
|
-
? respond(`Playing ${
|
|
1723
|
+
? respond(`Playing ${tuned.name}.`)
|
|
1570
1724
|
: respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false);
|
|
1571
1725
|
}
|
|
1572
1726
|
if (command.type === 'pause') {
|
|
1573
|
-
const control = await
|
|
1727
|
+
const control = await engine.pause();
|
|
1574
1728
|
return respond(control.message ?? 'Paused.', control.ok);
|
|
1575
1729
|
}
|
|
1576
1730
|
if (command.type === 'resume') {
|
|
1577
|
-
const
|
|
1731
|
+
const station = playingStationRef.current;
|
|
1732
|
+
if (command.restartStopped && station && ['idle', 'stopped'].includes(player.getState().state)) {
|
|
1733
|
+
await playStation(station, { queue: playbackQueueRef.current ?? queueFromCurrentList(station), signal });
|
|
1734
|
+
return respond(`Playing ${station.name}.`, player.getState().ready && player.getState().state === 'playing');
|
|
1735
|
+
}
|
|
1736
|
+
const control = await engine.resume();
|
|
1578
1737
|
return respond(control.message ?? 'Resumed.', control.ok);
|
|
1579
1738
|
}
|
|
1580
1739
|
if (command.type === 'stop') {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1740
|
+
cancelPendingTune();
|
|
1741
|
+
await engine.stop({ retainSelection: command.retainSelection });
|
|
1742
|
+
if (command.retainSelection) {
|
|
1743
|
+
setTuningStation(null);
|
|
1744
|
+
desktopMediaRef.current?.refresh();
|
|
1745
|
+
return respond('Stopped playback.');
|
|
1746
|
+
}
|
|
1584
1747
|
playingStationRef.current = null;
|
|
1585
1748
|
setPlayingStation(null);
|
|
1586
1749
|
setTuningStation(null);
|
|
@@ -1588,9 +1751,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1588
1751
|
return respond('Stopped RadioCLI.');
|
|
1589
1752
|
}
|
|
1590
1753
|
if (command.type === 'alarm-preempt') {
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1754
|
+
desktopSuspendedRef.current = true;
|
|
1755
|
+
desktopMediaRef.current?.refresh();
|
|
1756
|
+
cancelPendingTune();
|
|
1757
|
+
await engine.stop();
|
|
1594
1758
|
playingStationRef.current = null;
|
|
1595
1759
|
setPlayingStation(null);
|
|
1596
1760
|
setTuningStation(null);
|
|
@@ -1598,9 +1762,16 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1598
1762
|
return respond('Interactive playback yielded to the alarm.');
|
|
1599
1763
|
}
|
|
1600
1764
|
if (command.type === 'set-volume') {
|
|
1601
|
-
const control =
|
|
1765
|
+
const control = command.unmute
|
|
1766
|
+
? await player.setVolume(clampVolume(command.volume), { allowStopped: true })
|
|
1767
|
+
: await player.setVolume(clampVolume(command.volume));
|
|
1602
1768
|
if (control.ok)
|
|
1603
|
-
updateSettings({ volume: player.getState().volume });
|
|
1769
|
+
await updateSettings({ volume: player.getState().volume });
|
|
1770
|
+
if (control.ok && command.unmute && command.volume > 0) {
|
|
1771
|
+
const unmuted = await player.setMuted(false, { allowStopped: true });
|
|
1772
|
+
if (!unmuted.ok)
|
|
1773
|
+
return respond(unmuted.message ?? 'The output could not be unmuted.', false);
|
|
1774
|
+
}
|
|
1604
1775
|
return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
|
|
1605
1776
|
}
|
|
1606
1777
|
if (command.type === 'set-muted') {
|
|
@@ -1613,19 +1784,36 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1613
1784
|
return respond('No active station to favorite.', false);
|
|
1614
1785
|
const current = store.isFavorite(station);
|
|
1615
1786
|
if (current !== command.favorite)
|
|
1616
|
-
toggleFavorite(station);
|
|
1787
|
+
await toggleFavorite(station);
|
|
1617
1788
|
return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
|
|
1618
1789
|
}
|
|
1619
1790
|
if (command.type === 'airplay-list') {
|
|
1620
1791
|
if (networkPolicy().offline)
|
|
1621
1792
|
return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
|
|
1622
|
-
|
|
1793
|
+
let devices;
|
|
1794
|
+
try {
|
|
1795
|
+
devices = await refreshAirPlayTargets(false);
|
|
1796
|
+
}
|
|
1797
|
+
catch (error) {
|
|
1798
|
+
if (error instanceof PlaybackOutputError)
|
|
1799
|
+
return respond(error.message, false, []);
|
|
1800
|
+
throw error;
|
|
1801
|
+
}
|
|
1623
1802
|
return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
|
|
1624
1803
|
}
|
|
1625
1804
|
if (command.type === 'airplay-select') {
|
|
1626
1805
|
if (networkPolicy().offline)
|
|
1627
1806
|
return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false);
|
|
1628
|
-
|
|
1807
|
+
let devices;
|
|
1808
|
+
try {
|
|
1809
|
+
devices = await refreshAirPlayTargets(false);
|
|
1810
|
+
}
|
|
1811
|
+
catch (error) {
|
|
1812
|
+
if (error instanceof PlaybackOutputError)
|
|
1813
|
+
return respond(error.message, false, []);
|
|
1814
|
+
throw error;
|
|
1815
|
+
}
|
|
1816
|
+
signal?.throwIfAborted();
|
|
1629
1817
|
const device = devices.find(item => item.id === command.deviceId);
|
|
1630
1818
|
if (!device)
|
|
1631
1819
|
return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
|
|
@@ -1633,18 +1821,19 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1633
1821
|
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1634
1822
|
if (!backend)
|
|
1635
1823
|
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1636
|
-
updateSettings({ preferredBackend: backend });
|
|
1824
|
+
await updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: backend });
|
|
1825
|
+
signal?.throwIfAborted();
|
|
1637
1826
|
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1638
|
-
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1827
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
|
|
1639
1828
|
}
|
|
1640
1829
|
return respond(`Audio output set to this device (${backend}).`);
|
|
1641
1830
|
}
|
|
1642
1831
|
if (!isAirPlayBackendAvailable(availableBackends))
|
|
1643
1832
|
return respond('AirPlay playback is unavailable; run radiocli doctor.', false);
|
|
1644
|
-
updateSettings({ preferredAirPlayDevice: device.id });
|
|
1645
|
-
|
|
1833
|
+
await updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: 'airplay' });
|
|
1834
|
+
signal?.throwIfAborted();
|
|
1646
1835
|
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1647
|
-
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1836
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
|
|
1648
1837
|
}
|
|
1649
1838
|
return respond(`Audio output set to AirPlay receiver ${device.name}.`);
|
|
1650
1839
|
}
|
|
@@ -1652,20 +1841,26 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1652
1841
|
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1653
1842
|
if (!backend)
|
|
1654
1843
|
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1655
|
-
updateSettings({ preferredBackend: backend });
|
|
1844
|
+
await updateSettings({ preferredBackend: backend });
|
|
1845
|
+
signal?.throwIfAborted();
|
|
1656
1846
|
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1657
|
-
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1847
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
|
|
1658
1848
|
}
|
|
1659
1849
|
return respond(`Audio output set to this device (${backend}).`);
|
|
1660
1850
|
}
|
|
1661
1851
|
if (command.type === 'airplay-passcode') {
|
|
1662
|
-
const control = player.submitAirPlayPasscode(command.code);
|
|
1852
|
+
const control = await player.submitAirPlayPasscode(command.code);
|
|
1663
1853
|
return respond(control.message ?? 'AirPlay code sent.', control.ok);
|
|
1664
1854
|
}
|
|
1665
1855
|
if (command.type === 'update-settings') {
|
|
1666
|
-
|
|
1856
|
+
if (!allowedRadioSessionSettings(command.settings))
|
|
1857
|
+
return respond('Session settings only accept appearance and agent control. Use radiocli setup --airplay to enable Linux AirPlay.', false);
|
|
1858
|
+
await updateSettings(command.settings);
|
|
1667
1859
|
return respond('RadioCLI settings updated.');
|
|
1668
1860
|
}
|
|
1861
|
+
if (command.type !== 'next' && command.type !== 'previous')
|
|
1862
|
+
throw new Error('Unsupported radio command.');
|
|
1863
|
+
signal?.throwIfAborted();
|
|
1669
1864
|
const active = playingStationRef.current;
|
|
1670
1865
|
const queue = playbackQueueRef.current;
|
|
1671
1866
|
if (!active || !queue?.stations.length)
|
|
@@ -1675,33 +1870,80 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1675
1870
|
const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length];
|
|
1676
1871
|
if (!next)
|
|
1677
1872
|
return respond('No adjacent station is available.', false);
|
|
1678
|
-
|
|
1679
|
-
|
|
1873
|
+
if (command.preserveStop && ['idle', 'stopped'].includes(player.getState().state)) {
|
|
1874
|
+
engine.select(next, queue.stations);
|
|
1875
|
+
playingStationRef.current = next;
|
|
1876
|
+
setPlayingStation(next);
|
|
1877
|
+
setNowPlaying(null);
|
|
1878
|
+
desktopMediaRef.current?.refresh();
|
|
1879
|
+
return respond(`Selected ${next.name}.`);
|
|
1880
|
+
}
|
|
1881
|
+
const startPaused = command.preservePause && player.getState().state === 'paused';
|
|
1882
|
+
await playStation(next, { queue, startPaused, signal });
|
|
1883
|
+
const state = player.getState();
|
|
1884
|
+
const ok = playingStationRef.current && stationKey(playingStationRef.current) === stationKey(next) && state.ready && ['playing', 'paused'].includes(state.state);
|
|
1885
|
+
return respond(`${startPaused ? 'Selected' : 'Playing'} ${next.name}.`, Boolean(ok));
|
|
1680
1886
|
};
|
|
1887
|
+
activeAlarmRef.current = alarmTui.activeAlarms.length > 0;
|
|
1888
|
+
useEffect(() => {
|
|
1889
|
+
if (playbackSession || !stdin.isTTY || !desktopPolicy.enabled || !controlSessionReady)
|
|
1890
|
+
return;
|
|
1891
|
+
const media = startDesktopMediaControls({
|
|
1892
|
+
player, identity: store.filePath, policy: desktopPolicy,
|
|
1893
|
+
status: () => ({ owner: 'tui', playback: player.getState(), station: playingStationRef.current, queue: playbackQueueRef.current?.stations ?? [] }),
|
|
1894
|
+
command: (command, permitted) => dispatchOwnerCommand(command, () => permitted?.() !== false && !desktopSuspendedRef.current && !activeAlarmRef.current),
|
|
1895
|
+
suspended: () => desktopSuspendedRef.current || activeAlarmRef.current
|
|
1896
|
+
});
|
|
1897
|
+
desktopMediaRef.current = media;
|
|
1898
|
+
return () => { media.close(); if (desktopMediaRef.current === media)
|
|
1899
|
+
desktopMediaRef.current = null; };
|
|
1900
|
+
}, [controlSessionReady, desktopPolicy, dispatchOwnerCommand, player, stdin, store, playbackSession]);
|
|
1901
|
+
useEffect(() => { desktopMediaRef.current?.refresh(); }, [alarmTui.activeAlarms.length]);
|
|
1681
1902
|
useEffect(() => {
|
|
1682
|
-
if (!stdin.isTTY
|
|
1903
|
+
if (!stdin.isTTY)
|
|
1904
|
+
return;
|
|
1905
|
+
if (playbackSession) {
|
|
1906
|
+
if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled) {
|
|
1907
|
+
void playbackSession.call(initialAgentCommand).catch(reportActionError);
|
|
1908
|
+
}
|
|
1683
1909
|
return;
|
|
1910
|
+
}
|
|
1684
1911
|
let disposed = false;
|
|
1685
1912
|
let close;
|
|
1686
|
-
void startRadioSession(
|
|
1913
|
+
void startRadioSession(async (command, source) => {
|
|
1914
|
+
const permitted = async () => {
|
|
1915
|
+
if (source !== 'cli' && command.type !== 'status' && command.type !== 'alarm-preempt')
|
|
1916
|
+
await store.readFresh();
|
|
1917
|
+
if (disposed)
|
|
1918
|
+
throw new Error('RadioCLI control session is closed.');
|
|
1919
|
+
if (source !== 'cli' && !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled && command.type !== 'status' && command.type !== 'alarm-preempt') {
|
|
1920
|
+
throw new Error('Agent controls are disabled.');
|
|
1921
|
+
}
|
|
1922
|
+
return true;
|
|
1923
|
+
};
|
|
1924
|
+
return dispatchOwnerCommand(command, permitted);
|
|
1925
|
+
}, undefined, { queue: 'owner' }).then(async (session) => {
|
|
1687
1926
|
if (disposed) {
|
|
1688
1927
|
await session.close();
|
|
1689
1928
|
return;
|
|
1690
1929
|
}
|
|
1691
1930
|
close = session.close;
|
|
1692
|
-
|
|
1693
|
-
|
|
1931
|
+
setControlSessionReady(true);
|
|
1932
|
+
if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
|
|
1933
|
+
await dispatchOwnerCommand(initialAgentCommand);
|
|
1694
1934
|
}).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.'));
|
|
1695
1935
|
return () => {
|
|
1696
1936
|
disposed = true;
|
|
1937
|
+
setControlSessionReady(false);
|
|
1697
1938
|
if (close)
|
|
1698
1939
|
void close();
|
|
1699
1940
|
};
|
|
1700
|
-
}, [initialAgentCommand,
|
|
1941
|
+
}, [dispatchOwnerCommand, initialAgentCommand, stdin, store, playbackSession, reportActionError]);
|
|
1701
1942
|
useAppInput({
|
|
1702
1943
|
adjustVolume,
|
|
1703
1944
|
airPlayCode,
|
|
1704
1945
|
canEnterAirPlayCode,
|
|
1946
|
+
airPlaySupported,
|
|
1705
1947
|
cancelPendingAutoSkip,
|
|
1706
1948
|
copyStationUrl,
|
|
1707
1949
|
openStationHomepage,
|
|
@@ -1741,7 +1983,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1741
1983
|
moveExploreCursorToCell: moveExploreMapCursorToCell,
|
|
1742
1984
|
refreshProviderHealth,
|
|
1743
1985
|
refreshAirPlayTargets: () => {
|
|
1744
|
-
void refreshAirPlayTargets();
|
|
1986
|
+
void refreshAirPlayTargets().catch(reportActionError);
|
|
1745
1987
|
},
|
|
1746
1988
|
resetLearnedTransportKeys,
|
|
1747
1989
|
repairMcpIntegrations,
|
|
@@ -1749,7 +1991,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1749
1991
|
runSearch,
|
|
1750
1992
|
saveLearnedTransportKey,
|
|
1751
1993
|
screen,
|
|
1752
|
-
|
|
1994
|
+
screenRef,
|
|
1995
|
+
settingsPageRef,
|
|
1753
1996
|
searchQuery,
|
|
1754
1997
|
selectedRef,
|
|
1755
1998
|
selectedStationForInput,
|
|
@@ -1816,7 +2059,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1816
2059
|
(screen === 'search' && editingSearch) ||
|
|
1817
2060
|
screen === 'airplay-code');
|
|
1818
2061
|
const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
|
|
1819
|
-
const statusMessage = message ?? persistenceWarning ?? presenceWarning;
|
|
2062
|
+
const statusMessage = message ?? persistenceWarning ?? presenceWarning ?? (screen === 'search' ? stationContext.error ?? null : null);
|
|
1820
2063
|
const microFooter = footerMessage ?? statusMessage ?? (pageFooterOwnsCompactRow
|
|
1821
2064
|
? pageFooter
|
|
1822
2065
|
: footerPlayback.state === 'loading' && playbackFooter
|
|
@@ -1827,16 +2070,16 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1827
2070
|
const compactGlobalFooter = '←/→ tabs · ? help · q quit';
|
|
1828
2071
|
const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
|
|
1829
2072
|
const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter);
|
|
1830
|
-
const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
|
|
2073
|
+
const fullLegendRows = useMemo(() => balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve), [pageFooter, globalFooter, frameWidth, versionReserve]);
|
|
1831
2074
|
const compactStatus = footerMessage ?? statusMessage ?? playbackFooter;
|
|
1832
2075
|
const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
|
|
1833
|
-
const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
|
|
2076
|
+
const compactLegendRows = useMemo(() => balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve), [pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve]);
|
|
1834
2077
|
const microLegendWidth = Math.max(1, frameWidth - versionReserve);
|
|
1835
2078
|
const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage
|
|
1836
2079
|
? microFooter
|
|
1837
2080
|
: microShortcutFooterText(microFooter, microLegendWidth);
|
|
1838
2081
|
const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
|
|
1839
|
-
return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion: displayMode.reduceMotion, children: _jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlaySupported: airPlaySupported, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, searchQuery: searchQuery, screen: screen, settingsPage: settingsPage, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck, alarmTui: alarmTui }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: footerText(truncate(statusRow.text, frameWidth)) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: footerText(fullLegendRows[0] ?? ' ') }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(fullLegendRows[1] ?? ' ') }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] })] })) : (_jsx(_Fragment, { children: layout.mode === 'micro' ? _jsxs(Box, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || statusMessage ? themeAccent(theme) : textMuted, children: footerText(truncate(microLegend, microLegendWidth)) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }) : _jsxs(_Fragment, { children: [compactStatus ? _jsx(Text, { color: footerMessage || statusMessage ? themeAccent(theme) : textMuted, children: footerText(truncate(compactStatus, frameWidth)) }) : null, compactLegendRows.map((row, index) => index === compactLegendRows.length - 1 ? _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(row) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }, `legend-${index}`) : _jsx(Text, { color: index === 0 ? textMuted : textDim, children: footerText(row) }, `legend-${index}`))] }) })) })] }) }) }));
|
|
2082
|
+
return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion: displayMode.reduceMotion, children: _jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { platform: platform, airPlayDevices: availableAirPlayDevices, airPlaySupported: airPlaySupported, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, searchQuery: searchQuery, screen: screen, settingsPage: settingsPage, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck, alarmTui: alarmTui }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: footerText(truncate(statusRow.text, frameWidth)) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: footerText(fullLegendRows[0] ?? ' ') }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(fullLegendRows[1] ?? ' ') }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] })] })) : (_jsx(_Fragment, { children: layout.mode === 'micro' ? _jsxs(Box, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || statusMessage ? themeAccent(theme) : textMuted, children: footerText(truncate(microLegend, microLegendWidth)) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }) : _jsxs(_Fragment, { children: [compactStatus ? _jsx(Text, { color: footerMessage || statusMessage ? themeAccent(theme) : textMuted, children: footerText(truncate(compactStatus, frameWidth)) }) : null, compactLegendRows.map((row, index) => index === compactLegendRows.length - 1 ? _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(row) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }, `legend-${index}`) : _jsx(Text, { color: index === 0 ? textMuted : textDim, children: footerText(row) }, `legend-${index}`))] }) })) })] }) }) }));
|
|
1840
2083
|
}
|
|
1841
2084
|
function mcpRuntime() {
|
|
1842
2085
|
return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
|
|
@@ -1891,6 +2134,10 @@ function appendUniqueStations(current, page) {
|
|
|
1891
2134
|
}
|
|
1892
2135
|
return stations;
|
|
1893
2136
|
}
|
|
2137
|
+
function discoverySourceWarning(sources) {
|
|
2138
|
+
const unavailable = [...new Set(sources.filter(source => source.status === 'unavailable').map(source => source.provider === 'radio-browser' ? 'Radio Browser' : source.provider === 'radio-garden' ? 'Radio Garden' : source.provider))];
|
|
2139
|
+
return unavailable.length ? `Some sources are unavailable (${unavailable.join(', ')}). Showing available results.` : undefined;
|
|
2140
|
+
}
|
|
1894
2141
|
function formatCountryStationsSubtitle(country, loaded, hasMore) {
|
|
1895
2142
|
const total = country.stationCount.toLocaleString();
|
|
1896
2143
|
const loadedLabel = loaded.toLocaleString();
|
|
@@ -1994,3 +2241,10 @@ function mouseVisibleRows(screen, layout) {
|
|
|
1994
2241
|
return layout.stationRows;
|
|
1995
2242
|
return layout.contentRows;
|
|
1996
2243
|
}
|
|
2244
|
+
function localSessionLibrary(library, platform, selectionRequired = true) {
|
|
2245
|
+
return platform === 'linux' && selectionRequired && library.settings.preferredBackend === 'airplay'
|
|
2246
|
+
? { ...library, settings: { ...library.settings, preferredBackend: 'auto' } } : library;
|
|
2247
|
+
}
|
|
2248
|
+
function legacyDiscoveryPage(items, limit) {
|
|
2249
|
+
return { items, exhausted: items.length < limit, sources: [] };
|
|
2250
|
+
}
|