@ciphore/radiocli 0.2.3 → 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 +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- 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 +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- 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 +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- 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 +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- 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 +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- 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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
|
|
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 { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
|
|
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';
|
|
@@ -21,29 +24,31 @@ import { useAppInput } from './use-app-input.js';
|
|
|
21
24
|
import { useCommandExecutor } from './use-command-executor.js';
|
|
22
25
|
import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
|
|
23
26
|
import { isAirPlayBackendAvailable } from './airplay-settings.js';
|
|
27
|
+
import { networkPolicy } from '../platform/network.js';
|
|
24
28
|
import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
|
|
25
29
|
import { copyToClipboard, openExternal } from './system-actions.js';
|
|
30
|
+
import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
26
31
|
import { appVersion } from '../version.js';
|
|
27
32
|
import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
|
|
28
|
-
import {
|
|
33
|
+
import { airPlayPolicy, airPlaySetupRequiredMessage } from '../player/airplay-policy.js';
|
|
34
|
+
import { helpItemCountForAirPlay } from './help-content.js';
|
|
29
35
|
import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
|
|
30
36
|
import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
|
|
31
37
|
import { useAlarmTui } from './use-alarm-tui.js';
|
|
32
38
|
import { toAsciiSafe } from './ascii.js';
|
|
33
39
|
import { registerTuiPresence } from '../alarms/tui-presence.js';
|
|
34
40
|
import { configureMcpIntegrations } from '../agent/mcp-install.js';
|
|
35
|
-
import { startRadioSession } from '../agent/session.js';
|
|
36
|
-
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';
|
|
37
44
|
import { ReceiverAnimationProvider } from './receiver-animation.js';
|
|
38
45
|
import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
|
|
39
46
|
const LOADING_SPINNER_MS = 120;
|
|
40
|
-
const
|
|
41
|
-
const LISTENING_HEARTBEAT_MS = 30_000;
|
|
47
|
+
const TRANSIENT_NOTICE_MS = 4500;
|
|
42
48
|
const COUNTRY_STATIONS_PAGE_SIZE = 120;
|
|
43
49
|
const COUNTRY_STATIONS_LOAD_AHEAD = 12;
|
|
44
50
|
const SEARCH_RESULTS_PAGE_SIZE = 90;
|
|
45
51
|
const SEARCH_RESULTS_LOAD_AHEAD = 12;
|
|
46
|
-
const BROKEN_STREAM_AUTO_SKIP_DELAY_MS = 1000;
|
|
47
52
|
const settingToggleLabel = {
|
|
48
53
|
resumeOnLaunch: 'Resume on launch',
|
|
49
54
|
transparentBackground: 'Transparent background',
|
|
@@ -52,33 +57,40 @@ const settingToggleLabel = {
|
|
|
52
57
|
mouseSupport: 'Mouse and trackpad scrolling',
|
|
53
58
|
automaticUpdateChecks: 'Automatic update checks'
|
|
54
59
|
};
|
|
55
|
-
export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate }) {
|
|
60
|
+
export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate, platform = process.platform, playbackSession, playbackRecoveryWarning }) {
|
|
56
61
|
const { exit } = useApp();
|
|
57
62
|
const { stdin } = useStdin();
|
|
58
63
|
const { stdout } = useStdout();
|
|
59
64
|
const { columns, rows } = useWindowSize();
|
|
60
|
-
const
|
|
65
|
+
const screenReader = useIsScreenReaderEnabled();
|
|
66
|
+
const store = useMemo(() => new LibraryRepository(providedStore ?? new JsonLibraryStore(), { worker: !providedStore }), [providedStore]);
|
|
61
67
|
const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
|
|
62
68
|
const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
|
|
63
69
|
const installedVersion = useMemo(() => appVersion(), []);
|
|
64
|
-
const [library, setLibrary] = useState(() => store.snapshot());
|
|
70
|
+
const [library, setLibrary] = useState(() => localSessionLibrary(store.snapshot(), platform, playbackSession?.status.tui?.selectionRequired));
|
|
65
71
|
const settingsRef = useRef(library.settings);
|
|
66
|
-
|
|
67
|
-
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]);
|
|
68
75
|
const [playback, setPlayback] = useState(() => player.getState());
|
|
69
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;
|
|
70
80
|
const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
|
|
71
|
-
const [screen, setScreen] = useState('home');
|
|
81
|
+
const [screen, setScreen] = useState(playbackSession?.status.station ? 'now-playing' : 'home');
|
|
72
82
|
const [selected, setSelected] = useState(0);
|
|
73
83
|
const [settingsPage, setSettingsPage] = useState('root');
|
|
74
84
|
const [message, setMessage] = useState(null);
|
|
85
|
+
const [persistenceWarning, setPersistenceWarning] = useState(null);
|
|
86
|
+
const [presenceWarning, setPresenceWarning] = useState(playbackRecoveryWarning ?? null);
|
|
75
87
|
const [footerMessage, setFooterMessage] = useState(null);
|
|
76
88
|
const [countries, setCountries] = useState([]);
|
|
77
89
|
const [countryFilter, setCountryFilter] = useState('');
|
|
78
90
|
const [editingCountryFilter, setEditingCountryFilter] = useState(false);
|
|
79
91
|
const [loadingCountries, setLoadingCountries] = useState(false);
|
|
80
92
|
const [stationContexts, setStationContexts] = useState(initialStationContexts);
|
|
81
|
-
const [
|
|
93
|
+
const [loadingStationContexts, setLoadingStationContexts] = useState({});
|
|
82
94
|
const [searchQuery, setSearchQuery] = useState('');
|
|
83
95
|
const [editingSearch, setEditingSearch] = useState(true);
|
|
84
96
|
const [playingStation, setPlayingStation] = useState(null);
|
|
@@ -92,20 +104,50 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
92
104
|
const [commandText, setCommandText] = useState('');
|
|
93
105
|
const [airPlayCode, setAirPlayCode] = useState('');
|
|
94
106
|
const [filters, setFilters] = useState({ codec: null, language: null, minBitrate: null });
|
|
95
|
-
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]);
|
|
96
114
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
97
115
|
const [capturingTransportAction, setCapturingTransportAction] = useState(null);
|
|
116
|
+
const reportActionError = useCallback((error) => {
|
|
117
|
+
setMessage(sanitizeTerminalText(error instanceof Error ? error.message : String(error)) ?? 'Could not complete this action.');
|
|
118
|
+
}, []);
|
|
119
|
+
// Optional history cannot change the result of playback or navigation. Keep
|
|
120
|
+
// its warning until an explicit save succeeds; a no-op checkpoint proves nothing.
|
|
121
|
+
const persistLibrary = useCallback(async (write) => {
|
|
122
|
+
try {
|
|
123
|
+
const nextLibrary = localSessionLibrary(await write(), platform, player.airPlaySelectionRequired());
|
|
124
|
+
setLibrary(nextLibrary);
|
|
125
|
+
return nextLibrary;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const detail = sanitizeTerminalText(error instanceof Error ? error.message : String(error));
|
|
129
|
+
setPersistenceWarning(`Library not saved. Set RADIOCLI_HOME to a writable directory.${detail ? ` ${detail}` : ''}`);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
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]);
|
|
98
136
|
const announcedUpdateRef = useRef(false);
|
|
99
137
|
const automaticUpdateCheckStartedRef = useRef(false);
|
|
100
138
|
const installingUpdateRef = useRef(false);
|
|
101
139
|
const configuringMcpRef = useRef(false);
|
|
102
140
|
const displayStationsRef = useRef([]);
|
|
103
|
-
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);
|
|
104
144
|
const lastRawTransportAtRef = useRef(0);
|
|
105
|
-
const
|
|
145
|
+
const loadingStationContextsRef = useRef({});
|
|
106
146
|
const countryPageRequestRef = useRef(null);
|
|
107
147
|
const searchPageRequestRef = useRef(null);
|
|
108
|
-
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)]);
|
|
109
151
|
const screenRef = useRef(screen);
|
|
110
152
|
const selectedRef = useRef(selected);
|
|
111
153
|
const settingsPageRef = useRef(settingsPage);
|
|
@@ -124,16 +166,45 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
124
166
|
const helpReturnScreenRef = useRef('home');
|
|
125
167
|
const countriesLoadAttemptedRef = useRef(false);
|
|
126
168
|
const tuneRequestRef = useRef(0);
|
|
169
|
+
const outputCancellationRef = useRef(0);
|
|
170
|
+
const pendingOutputChangesRef = useRef(0);
|
|
127
171
|
const searchRequestRef = useRef(0);
|
|
128
172
|
const countryRequestRef = useRef(0);
|
|
129
173
|
const nearbyRequestRef = useRef(0);
|
|
130
|
-
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]);
|
|
131
186
|
const volumeRequestRef = useRef(0);
|
|
132
187
|
const agentHandlerRef = useRef(async () => {
|
|
133
188
|
throw new Error('RadioCLI is still starting.');
|
|
134
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));
|
|
196
|
+
const showTransientMessage = useCallback((nextMessage) => {
|
|
197
|
+
if (transientMessageTimerRef.current) {
|
|
198
|
+
clearTimeout(transientMessageTimerRef.current);
|
|
199
|
+
}
|
|
200
|
+
setMessage(nextMessage);
|
|
201
|
+
transientMessageTimerRef.current = setTimeout(() => {
|
|
202
|
+
setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
203
|
+
transientMessageTimerRef.current = null;
|
|
204
|
+
}, TRANSIENT_NOTICE_MS);
|
|
205
|
+
}, []);
|
|
135
206
|
const theme = library.settings.theme;
|
|
136
|
-
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
207
|
+
const displayMode = useMemo(() => resolveDisplayMode(library.settings, process.env, { screenReader, isTTY: Boolean(stdout.isTTY), colorDepth: stdout.getColorDepth?.() }), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion, screenReader, stdout]);
|
|
137
208
|
const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
|
|
138
209
|
const diagnostics = player.diagnostics();
|
|
139
210
|
const filterLabel = formatFilterLabel(filters);
|
|
@@ -156,10 +227,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
156
227
|
screenRef.current = screen;
|
|
157
228
|
selectedRef.current = selected;
|
|
158
229
|
settingsPageRef.current = settingsPage;
|
|
159
|
-
loadingStationsRef.current = loadingStations;
|
|
160
230
|
stationContextsRef.current = activeStationContexts;
|
|
161
231
|
exploreCursorRef.current = exploreCursor;
|
|
162
232
|
const renderedStationContextKey = stationContextKeyForScreen(screen);
|
|
233
|
+
const loadingStations = renderedStationContextKey ? Boolean(loadingStationContexts[renderedStationContextKey]) : false;
|
|
163
234
|
const activeStationContextKey = renderedStationContextKey ?? lastStationContextKeyRef.current;
|
|
164
235
|
const stationContext = activeStationContexts[activeStationContextKey];
|
|
165
236
|
const stationCounts = useMemo(() => ({
|
|
@@ -186,8 +257,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
186
257
|
'alarm-ringing': 0,
|
|
187
258
|
'airplay-settings': 0,
|
|
188
259
|
'airplay-code': 1,
|
|
189
|
-
settings: settingsItemsForPage(settingsPage).length,
|
|
190
|
-
help:
|
|
260
|
+
settings: settingsItemsForPage(settingsPage, airPlaySupported).length,
|
|
261
|
+
help: helpItemCountForAirPlay(airPlaySupported)
|
|
191
262
|
});
|
|
192
263
|
itemCountsRef.current = {
|
|
193
264
|
home: homeItems.length,
|
|
@@ -206,8 +277,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
206
277
|
'alarm-ringing': 0,
|
|
207
278
|
'airplay-settings': availableAirPlayDevices.length,
|
|
208
279
|
'airplay-code': 1,
|
|
209
|
-
settings: settingsItemsForPage(settingsPage).length,
|
|
210
|
-
help:
|
|
280
|
+
settings: settingsItemsForPage(settingsPage, airPlaySupported).length,
|
|
281
|
+
help: helpItemCountForAirPlay(airPlaySupported)
|
|
211
282
|
};
|
|
212
283
|
const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
|
|
213
284
|
displayStationsRef.current = displayStations;
|
|
@@ -222,52 +293,69 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
222
293
|
Boolean(isAirPlayBackendAvailable(availableBackends) && selectedAirPlayDevice?.requiresPassword && !selectedAirPlayDevice.local);
|
|
223
294
|
const layout = computeTerminalLayout(columns, rows, footerRows);
|
|
224
295
|
const frameWidth = layout.frameWidth;
|
|
225
|
-
const mouseReportingActive = !
|
|
296
|
+
const mouseReportingActive = !displayMode.screenReader &&
|
|
297
|
+
process.env.TERM?.toLowerCase() !== 'dumb' &&
|
|
298
|
+
!commandMode &&
|
|
226
299
|
!capturingTransportAction &&
|
|
227
300
|
!editingCountryFilter &&
|
|
228
301
|
shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
|
|
229
|
-
useEffect(() => player.onChange(setPlayback), [player]);
|
|
230
|
-
useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]);
|
|
231
|
-
const playingStationRef = useRef(null);
|
|
232
|
-
playingStationRef.current = playingStation;
|
|
233
|
-
const activeListeningStationRef = useRef(null);
|
|
234
|
-
const lastRecordedTrackRef = useRef(null);
|
|
235
302
|
useEffect(() => {
|
|
236
|
-
|
|
237
|
-
const isAudible = playback.state === 'playing' && playback.ready && station;
|
|
238
|
-
if (isAudible) {
|
|
239
|
-
const key = stationKey(station);
|
|
240
|
-
if (activeListeningStationRef.current !== key) {
|
|
241
|
-
activeListeningStationRef.current = key;
|
|
242
|
-
setLibrary(store.startListeningSession(station));
|
|
243
|
-
}
|
|
303
|
+
if (!stdin.isTTY)
|
|
244
304
|
return;
|
|
305
|
+
try {
|
|
306
|
+
const unregister = registerTuiPresence();
|
|
307
|
+
return () => {
|
|
308
|
+
try {
|
|
309
|
+
unregister();
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
console.error('RadioCLI could not remove its alarm-control presence marker. Stale markers are checked on the next launch.');
|
|
313
|
+
}
|
|
314
|
+
};
|
|
245
315
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
setLibrary(store.finishActiveListeningSession());
|
|
249
|
-
}
|
|
250
|
-
}, [playback.ready, playback.state, playingStation, store]);
|
|
251
|
-
useEffect(() => {
|
|
252
|
-
if (playback.state !== 'playing' || !playback.ready || !playingStation) {
|
|
253
|
-
return;
|
|
316
|
+
catch {
|
|
317
|
+
setPresenceWarning([playbackRecoveryWarning, 'Alarm controls cannot register this terminal in the runtime directory. Browsing and playback remain available.'].filter(Boolean).join(' '));
|
|
254
318
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
319
|
+
}, [stdin, playbackRecoveryWarning]);
|
|
320
|
+
const playingStationRef = useRef(null);
|
|
321
|
+
playingStationRef.current = playingStation;
|
|
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');
|
|
337
|
+
}
|
|
269
338
|
}
|
|
270
|
-
|
|
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]);
|
|
344
|
+
useEffect(() => {
|
|
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]);
|
|
271
359
|
useEffect(() => {
|
|
272
360
|
if (!stdout.isTTY || !mouseReportingActive) {
|
|
273
361
|
return;
|
|
@@ -285,7 +373,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
285
373
|
}, [renderedStationContextKey, screen, selected]);
|
|
286
374
|
useEffect(() => {
|
|
287
375
|
if (footerPlayback.state !== 'loading' ||
|
|
288
|
-
|
|
376
|
+
displayMode.reduceMotion ||
|
|
289
377
|
process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
|
|
290
378
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
291
379
|
setSpinnerFrame(0);
|
|
@@ -293,7 +381,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
293
381
|
}
|
|
294
382
|
const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
|
|
295
383
|
return () => clearInterval(timer);
|
|
296
|
-
}, [footerPlayback.state,
|
|
384
|
+
}, [footerPlayback.state, displayMode.reduceMotion]);
|
|
297
385
|
useEffect(() => {
|
|
298
386
|
if ((screen === 'countries' || screen === 'map') &&
|
|
299
387
|
countries.length === 0 &&
|
|
@@ -309,6 +397,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
309
397
|
}
|
|
310
398
|
}, [countries.length, loadingCountries, providers, screen]);
|
|
311
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;
|
|
312
408
|
if (exploreMoveTimerRef.current) {
|
|
313
409
|
clearTimeout(exploreMoveTimerRef.current);
|
|
314
410
|
}
|
|
@@ -318,41 +414,41 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
318
414
|
if (transientFooterMessageTimerRef.current) {
|
|
319
415
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
320
416
|
}
|
|
321
|
-
if (skipBrokenTimerRef.current) {
|
|
322
|
-
clearTimeout(skipBrokenTimerRef.current);
|
|
323
|
-
}
|
|
324
417
|
}, []);
|
|
325
418
|
useEffect(() => {
|
|
326
419
|
setSelected(value => clamp(value, currentItemCount(screen) - 1));
|
|
327
420
|
}, [availableAirPlayDevices.length, displayStations.length, filteredCountries.length, screen]);
|
|
328
421
|
useEffect(() => {
|
|
422
|
+
if (playbackSession)
|
|
423
|
+
return;
|
|
329
424
|
if (!sleepUntil) {
|
|
330
425
|
return;
|
|
331
426
|
}
|
|
332
427
|
const delayMs = sleepUntil - Date.now();
|
|
333
428
|
if (delayMs <= 0) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
void player.stop();
|
|
429
|
+
cancelPendingTune();
|
|
430
|
+
void engine.stop({ retainSelection: true }).catch(reportActionError);
|
|
337
431
|
setSleepUntil(null);
|
|
338
432
|
return;
|
|
339
433
|
}
|
|
340
434
|
const timer = setTimeout(() => {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
void player.stop();
|
|
435
|
+
cancelPendingTune();
|
|
436
|
+
void engine.stop({ retainSelection: true }).catch(reportActionError);
|
|
344
437
|
setSleepUntil(null);
|
|
345
438
|
}, delayMs);
|
|
346
439
|
return () => clearTimeout(timer);
|
|
347
|
-
}, [
|
|
440
|
+
}, [cancelPendingTune, engine, reportActionError, sleepUntil, playbackSession, setSleepUntil]);
|
|
348
441
|
useEffect(() => {
|
|
349
442
|
const backends = player.refreshDetectedBackends();
|
|
350
443
|
setAvailableBackends(backends);
|
|
351
|
-
|
|
444
|
+
const network = networkPolicy();
|
|
445
|
+
if (platform === 'darwin' && airPlaySupported && !network.offline && !network.lowBandwidth) {
|
|
446
|
+
void player.refreshAirPlayDevices().then(setAvailableAirPlayDevices).catch(() => setAvailableAirPlayDevices([]));
|
|
447
|
+
}
|
|
352
448
|
if (backends.length === 0) {
|
|
353
449
|
setMessage(`No playback backend found. ${playbackBackendInstallHint()}`);
|
|
354
450
|
}
|
|
355
|
-
}, [player]);
|
|
451
|
+
}, [airPlaySupported, platform, player]);
|
|
356
452
|
const refreshProviderHealth = useCallback(() => {
|
|
357
453
|
providers.health(settingsRef.current).then(setProviderHealth).catch(() => setProviderHealth({}));
|
|
358
454
|
}, [providers]);
|
|
@@ -361,9 +457,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
361
457
|
}, [refreshProviderHealth]);
|
|
362
458
|
const refreshUpdateCheck = useCallback(async () => {
|
|
363
459
|
const updateCheck = await updateChecker({ currentVersion: installedVersion });
|
|
364
|
-
|
|
460
|
+
persistLibrary(() => store.updateCheckState(updateCheck));
|
|
365
461
|
return updateCheck;
|
|
366
|
-
}, [installedVersion, store, updateChecker]);
|
|
462
|
+
}, [installedVersion, persistLibrary, store, updateChecker]);
|
|
367
463
|
useEffect(() => {
|
|
368
464
|
if (automaticUpdateCheckStartedRef.current ||
|
|
369
465
|
!automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
|
|
@@ -379,51 +475,93 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
379
475
|
announcedUpdateRef.current = true;
|
|
380
476
|
setMessage(`Update available: v${updateCheck.latestVersion} · run :update`);
|
|
381
477
|
}
|
|
478
|
+
}).catch(error => {
|
|
479
|
+
if (!cancelled)
|
|
480
|
+
reportActionError(error);
|
|
382
481
|
});
|
|
383
482
|
return () => {
|
|
384
483
|
cancelled = true;
|
|
385
484
|
};
|
|
386
|
-
}, [library.settings.automaticUpdateChecks, refreshUpdateCheck]);
|
|
485
|
+
}, [library.settings.automaticUpdateChecks, refreshUpdateCheck, reportActionError]);
|
|
387
486
|
const setStationContextFor = useCallback((key, context) => {
|
|
487
|
+
stationContextsRef.current = { ...stationContextsRef.current, [key]: context };
|
|
388
488
|
setStationContexts(current => ({ ...current, [key]: context }));
|
|
389
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
|
+
}, []);
|
|
390
496
|
const stationMatches = useCallback((left, right) => stationKey(left) === stationKey(right), []);
|
|
391
497
|
const queueContainsStation = useCallback((queue, station) => Boolean(queue?.stations.some(item => stationMatches(item, station))), [stationMatches]);
|
|
392
|
-
const updateSettings = useCallback((
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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]);
|
|
398
530
|
useEffect(() => {
|
|
399
531
|
if (!selectedAirPlayDevice?.local || settingsRef.current.preferredBackend !== 'airplay') {
|
|
400
532
|
return;
|
|
401
533
|
}
|
|
402
534
|
const preferredBackend = preferredLocalPlaybackBackend(availableBackends);
|
|
403
535
|
if (!preferredBackend) {
|
|
404
|
-
setMessage(`${selectedAirPlayDevice.name} is
|
|
536
|
+
setMessage(`${selectedAirPlayDevice.name} is ${airPlayLocalDeviceLabel(platform)}. Install mpv to use local playback instead of AirPlay.`);
|
|
405
537
|
return;
|
|
406
538
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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]);
|
|
413
550
|
const beginLearningTransportKey = useCallback((action) => {
|
|
414
551
|
setCapturingTransportAction(action);
|
|
415
552
|
setMessage(`Press a key for ${mediaActionLabel(action)}. Esc cancels.`);
|
|
416
553
|
}, []);
|
|
417
554
|
const resetLearnedTransportKeys = useCallback(() => {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
555
|
+
void updateSettings({ mediaKeys: { previous: [], playPause: [], next: [] } })
|
|
556
|
+
.then(() => setMessage('Learned media keys reset. Built-in fallbacks still work.'))
|
|
557
|
+
.catch(() => undefined);
|
|
558
|
+
}, [updateSettings]);
|
|
421
559
|
const saveLearnedTransportKey = useCallback((action, input) => {
|
|
422
|
-
const mediaKeys = addMediaKeyBinding(settingsRef.current.mediaKeys, action, input);
|
|
423
|
-
updateMediaKeys(mediaKeys);
|
|
424
560
|
setCapturingTransportAction(null);
|
|
425
|
-
|
|
426
|
-
|
|
561
|
+
void updateSettings(current => ({ mediaKeys: addMediaKeyBinding(current.mediaKeys, action, input) }))
|
|
562
|
+
.then(() => setMessage(`Learned ${mediaActionLabel(action)} key.`))
|
|
563
|
+
.catch(() => undefined);
|
|
564
|
+
}, [updateSettings]);
|
|
427
565
|
const go = useCallback((next, options = {}) => {
|
|
428
566
|
if (next === 'help' && screenRef.current !== 'help') {
|
|
429
567
|
helpReturnScreenRef.current = screenRef.current;
|
|
@@ -437,27 +575,30 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
437
575
|
screenRef.current !== 'airplay-code') {
|
|
438
576
|
settingsPageRef.current = 'root';
|
|
439
577
|
setSettingsPage('root');
|
|
440
|
-
itemCountsRef.current.settings = settingsItemsForPage('root').length;
|
|
578
|
+
itemCountsRef.current.settings = settingsItemsForPage('root', airPlaySupported).length;
|
|
441
579
|
selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0;
|
|
442
580
|
}
|
|
443
581
|
selectedByScreenRef.current[screenRef.current] = selectedRef.current;
|
|
444
582
|
const remembered = selectedByScreenRef.current[destination] ?? 0;
|
|
445
|
-
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;
|
|
446
587
|
setScreen(destination);
|
|
447
|
-
setSelected(
|
|
588
|
+
setSelected(nextSelection);
|
|
448
589
|
if (options.clearMessage !== false) {
|
|
449
590
|
setMessage(null);
|
|
450
591
|
}
|
|
451
|
-
}, []);
|
|
592
|
+
}, [airPlaySupported]);
|
|
452
593
|
const openSettingsPage = useCallback((next) => {
|
|
453
594
|
selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current;
|
|
454
595
|
settingsPageRef.current = next;
|
|
455
596
|
setSettingsPage(next);
|
|
456
|
-
const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next).length - 1);
|
|
597
|
+
const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next, airPlaySupported).length - 1);
|
|
457
598
|
selectedRef.current = nextSelection;
|
|
458
599
|
setSelected(nextSelection);
|
|
459
600
|
setMessage(null);
|
|
460
|
-
}, []);
|
|
601
|
+
}, [airPlaySupported]);
|
|
461
602
|
const closeSettingsPage = useCallback(() => {
|
|
462
603
|
if (settingsPageRef.current === 'root') {
|
|
463
604
|
go('home');
|
|
@@ -466,9 +607,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
466
607
|
openSettingsPage('root');
|
|
467
608
|
}, [go, openSettingsPage]);
|
|
468
609
|
const openAirPlayCode = useCallback(() => {
|
|
610
|
+
if (!airPlaySupported) {
|
|
611
|
+
showTransientMessage(airPlayUnavailableMessage);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
469
614
|
setAirPlayCode('');
|
|
470
615
|
go('airplay-code', { resetSelection: true, clearMessage: false });
|
|
471
|
-
}, [go]);
|
|
616
|
+
}, [airPlaySupported, airPlayUnavailableMessage, go, showTransientMessage]);
|
|
472
617
|
useEffect(() => {
|
|
473
618
|
if (isAirPlayCodePromptActive(playback) && screenRef.current !== 'airplay-code') {
|
|
474
619
|
openAirPlayCode();
|
|
@@ -476,13 +621,18 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
476
621
|
}, [openAirPlayCode, playback.backend, playback.message]);
|
|
477
622
|
const shutdown = useCallback(() => {
|
|
478
623
|
tuneRequestRef.current += 1;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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]);
|
|
482
629
|
useEffect(() => {
|
|
483
630
|
const finishForSignal = () => {
|
|
484
|
-
|
|
485
|
-
|
|
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));
|
|
486
636
|
};
|
|
487
637
|
process.once('SIGTERM', finishForSignal);
|
|
488
638
|
process.once('SIGHUP', finishForSignal);
|
|
@@ -490,19 +640,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
490
640
|
process.off('SIGTERM', finishForSignal);
|
|
491
641
|
process.off('SIGHUP', finishForSignal);
|
|
492
642
|
};
|
|
493
|
-
}, [
|
|
494
|
-
const
|
|
495
|
-
setStationContextFor(stationContextKeyForScreen(next) ?? 'stations', context);
|
|
496
|
-
go(next, { resetSelection: options.resetSelection ?? true, clearMessage: options.clearMessage });
|
|
497
|
-
}, [go, setStationContextFor]);
|
|
498
|
-
const loadExploreAt = useCallback(async (cursor, options = {}) => {
|
|
643
|
+
}, [cancelPendingTune, engine, reportActionError, store]);
|
|
644
|
+
const loadExploreAt = useCallback(async (cursor) => {
|
|
499
645
|
const requestId = exploreRequestRef.current + 1;
|
|
500
646
|
exploreRequestRef.current = requestId;
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if (screenRef.current
|
|
504
|
-
|
|
505
|
-
}
|
|
647
|
+
const signal = beginDiscovery('explore');
|
|
648
|
+
setStationLoading('explore', true);
|
|
649
|
+
if (screenRef.current === 'explore')
|
|
650
|
+
setMessage(null);
|
|
506
651
|
exploreCursorRef.current = cursor;
|
|
507
652
|
setExploreCursor(cursor);
|
|
508
653
|
const previousExploreStations = stationContextsRef.current.explore.stations;
|
|
@@ -513,7 +658,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
513
658
|
error: undefined
|
|
514
659
|
});
|
|
515
660
|
try {
|
|
516
|
-
const stations = await providers.nearby(exploreCursorLocation(cursor), 90);
|
|
661
|
+
const stations = await providers.nearby(exploreCursorLocation(cursor), 90, { signal });
|
|
517
662
|
if (requestId !== exploreRequestRef.current) {
|
|
518
663
|
return;
|
|
519
664
|
}
|
|
@@ -526,7 +671,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
526
671
|
if (screenRef.current === 'explore') {
|
|
527
672
|
setSelected(0);
|
|
528
673
|
}
|
|
529
|
-
if (stations.length === 0) {
|
|
674
|
+
if (stations.length === 0 && screenRef.current === 'explore') {
|
|
530
675
|
setMessage(`No geotagged stations found near ${formatExploreCursor(cursor)}.`);
|
|
531
676
|
}
|
|
532
677
|
}
|
|
@@ -540,23 +685,25 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
540
685
|
subtitle: `World station directory unavailable near ${formatExploreCursor(cursor)}`,
|
|
541
686
|
error: loadError
|
|
542
687
|
});
|
|
543
|
-
|
|
688
|
+
if (screenRef.current === 'explore')
|
|
689
|
+
setMessage(loadError);
|
|
544
690
|
}
|
|
545
691
|
finally {
|
|
546
692
|
if (requestId === exploreRequestRef.current) {
|
|
547
|
-
|
|
693
|
+
setStationLoading('explore', false);
|
|
548
694
|
}
|
|
549
695
|
}
|
|
550
|
-
}, [
|
|
696
|
+
}, [beginDiscovery, providers, setStationContextFor, setStationLoading]);
|
|
551
697
|
const loadExplore = useCallback(async () => {
|
|
552
|
-
await loadExploreAt(exploreCursorRef.current
|
|
698
|
+
await loadExploreAt(exploreCursorRef.current);
|
|
553
699
|
}, [loadExploreAt]);
|
|
554
700
|
const moveExploreMapCursor = useCallback((direction, fast = false) => {
|
|
555
701
|
const next = shiftExploreCursor(exploreCursorRef.current, direction, fast);
|
|
702
|
+
exploreRequestRef.current += 1;
|
|
556
703
|
exploreCursorRef.current = next;
|
|
557
704
|
setExploreCursor(next);
|
|
558
705
|
setSelected(0);
|
|
559
|
-
|
|
706
|
+
setStationLoading('explore', true);
|
|
560
707
|
setStationContextFor('explore', {
|
|
561
708
|
title: 'Explore world',
|
|
562
709
|
subtitle: `Move cursor: ${formatExploreCursor(next)}`,
|
|
@@ -566,9 +713,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
566
713
|
clearTimeout(exploreMoveTimerRef.current);
|
|
567
714
|
}
|
|
568
715
|
exploreMoveTimerRef.current = setTimeout(() => {
|
|
569
|
-
|
|
716
|
+
exploreMoveTimerRef.current = null;
|
|
717
|
+
void loadExploreAt(next);
|
|
570
718
|
}, 220);
|
|
571
|
-
}, [loadExploreAt, setStationContextFor]);
|
|
719
|
+
}, [loadExploreAt, setStationContextFor, setStationLoading]);
|
|
572
720
|
const moveExploreMapCursorToCell = useCallback((x, y) => {
|
|
573
721
|
const next = exploreCursorForMouseCell(x, y, frameWidth, layout);
|
|
574
722
|
if (!next) {
|
|
@@ -576,84 +724,112 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
576
724
|
}
|
|
577
725
|
if (exploreMoveTimerRef.current) {
|
|
578
726
|
clearTimeout(exploreMoveTimerRef.current);
|
|
727
|
+
exploreMoveTimerRef.current = null;
|
|
579
728
|
}
|
|
580
|
-
void loadExploreAt(next
|
|
729
|
+
void loadExploreAt(next);
|
|
581
730
|
}, [frameWidth, layout, loadExploreAt]);
|
|
582
|
-
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');
|
|
583
739
|
const requestId = countryRequestRef.current + 1;
|
|
584
740
|
countryRequestRef.current = requestId;
|
|
585
|
-
|
|
586
|
-
setMessage(null);
|
|
741
|
+
setStationLoading('stations', true);
|
|
587
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 });
|
|
588
752
|
try {
|
|
589
|
-
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;
|
|
590
757
|
if (requestId !== countryRequestRef.current)
|
|
591
758
|
return;
|
|
592
|
-
|
|
759
|
+
setStationContextFor('stations', {
|
|
593
760
|
title: country.name,
|
|
594
|
-
subtitle: formatCountryStationsSubtitle(country, stations.length,
|
|
761
|
+
subtitle: formatCountryStationsSubtitle(country, stations.length, !page.exhausted),
|
|
595
762
|
stations,
|
|
596
763
|
country,
|
|
597
|
-
hasMore:
|
|
598
|
-
|
|
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);
|
|
599
770
|
}
|
|
600
771
|
catch (error) {
|
|
601
772
|
if (requestId !== countryRequestRef.current)
|
|
602
773
|
return;
|
|
603
|
-
|
|
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);
|
|
604
778
|
}
|
|
605
779
|
finally {
|
|
606
780
|
if (requestId === countryRequestRef.current)
|
|
607
|
-
|
|
781
|
+
setStationLoading('stations', false);
|
|
608
782
|
}
|
|
609
|
-
}, [providers,
|
|
783
|
+
}, [beginDiscovery, go, providers, setStationContextFor, setStationLoading]);
|
|
610
784
|
const loadMoreCountryStations = useCallback(async () => {
|
|
611
785
|
const context = stationContextsRef.current.stations;
|
|
612
786
|
const country = context.country;
|
|
613
|
-
if (!country || !context.hasMore ||
|
|
787
|
+
if (!country || !context.hasMore || loadingStationContextsRef.current.stations) {
|
|
614
788
|
return;
|
|
615
789
|
}
|
|
616
790
|
const offset = context.stations.length;
|
|
617
|
-
const
|
|
791
|
+
const requestId = countryRequestRef.current;
|
|
792
|
+
const requestKey = `${requestId}:${country.code}:${context.nextCursor ?? offset}`;
|
|
618
793
|
if (countryPageRequestRef.current === requestKey) {
|
|
619
794
|
return;
|
|
620
795
|
}
|
|
621
796
|
countryPageRequestRef.current = requestKey;
|
|
622
|
-
|
|
797
|
+
setStationLoading('stations', true);
|
|
623
798
|
try {
|
|
624
|
-
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);
|
|
625
802
|
const latest = stationContextsRef.current.stations;
|
|
626
|
-
if (latest.country?.code !== country.code || latest.stations.length !== offset) {
|
|
803
|
+
if (requestId !== countryRequestRef.current || latest.country?.code !== country.code || latest.stations.length !== offset) {
|
|
627
804
|
return;
|
|
628
805
|
}
|
|
629
|
-
const stations = appendUniqueStations(latest.stations, page);
|
|
630
|
-
const hasMore = page.
|
|
631
|
-
stations.length > latest.stations.length &&
|
|
632
|
-
stations.length < country.stationCount;
|
|
806
|
+
const stations = appendUniqueStations(latest.stations, page.items);
|
|
807
|
+
const hasMore = !page.exhausted;
|
|
633
808
|
setStationContextFor('stations', {
|
|
634
809
|
...latest,
|
|
635
810
|
subtitle: formatCountryStationsSubtitle(country, stations.length, hasMore),
|
|
636
811
|
stations,
|
|
637
|
-
hasMore
|
|
812
|
+
hasMore, nextCursor: page.nextCursor, sources: page.sources,
|
|
813
|
+
error: discoverySourceWarning(page.sources)
|
|
638
814
|
});
|
|
639
815
|
}
|
|
640
816
|
catch (error) {
|
|
641
|
-
if (stationContextsRef.current.stations.country?.code === country.code) {
|
|
817
|
+
if (requestId === countryRequestRef.current && screenRef.current === 'stations' && stationContextsRef.current.stations.country?.code === country.code) {
|
|
642
818
|
setMessage(error instanceof Error ? error.message : `Could not load more ${country.name} stations.`);
|
|
643
819
|
}
|
|
644
820
|
}
|
|
645
821
|
finally {
|
|
646
822
|
if (countryPageRequestRef.current === requestKey) {
|
|
647
823
|
countryPageRequestRef.current = null;
|
|
648
|
-
|
|
824
|
+
setStationLoading('stations', false);
|
|
649
825
|
}
|
|
650
826
|
}
|
|
651
|
-
}, [providers, setStationContextFor]);
|
|
827
|
+
}, [providers, setStationContextFor, setStationLoading]);
|
|
652
828
|
useEffect(() => {
|
|
653
829
|
if (screen === 'stations' &&
|
|
654
830
|
stationContexts.stations.country &&
|
|
655
831
|
stationContexts.stations.hasMore &&
|
|
656
|
-
|
|
832
|
+
stationCounts.stations - selected <= COUNTRY_STATIONS_LOAD_AHEAD) {
|
|
657
833
|
void loadMoreCountryStations();
|
|
658
834
|
}
|
|
659
835
|
}, [
|
|
@@ -662,101 +838,128 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
662
838
|
selected,
|
|
663
839
|
stationContexts.stations.country,
|
|
664
840
|
stationContexts.stations.hasMore,
|
|
665
|
-
|
|
841
|
+
stationCounts.stations,
|
|
842
|
+
stationContexts.stations.nextCursor
|
|
666
843
|
]);
|
|
667
844
|
const runSearch = useCallback(async (query = searchQuery) => {
|
|
668
845
|
if (!query.trim()) {
|
|
669
846
|
setMessage('Enter a station, genre, language, or place.');
|
|
670
847
|
return;
|
|
671
848
|
}
|
|
849
|
+
const signal = beginDiscovery('search');
|
|
672
850
|
const requestId = searchRequestRef.current + 1;
|
|
673
851
|
searchRequestRef.current = requestId;
|
|
674
|
-
|
|
675
|
-
|
|
852
|
+
activeSearchRef.current = { query: query.trim(), fingerprint: searchFingerprint };
|
|
853
|
+
searchPageRequestRef.current = null;
|
|
854
|
+
setStationLoading('search', true);
|
|
855
|
+
if (screenRef.current === 'search')
|
|
856
|
+
setMessage(null);
|
|
676
857
|
try {
|
|
677
|
-
const
|
|
858
|
+
const pageOptions = {
|
|
678
859
|
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
679
|
-
|
|
860
|
+
signal,
|
|
680
861
|
codec: filters.codec ?? undefined,
|
|
681
862
|
language: filters.language ?? undefined,
|
|
682
863
|
minBitrate: filters.minBitrate ?? undefined
|
|
683
|
-
}
|
|
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;
|
|
684
869
|
if (requestId !== searchRequestRef.current)
|
|
685
870
|
return;
|
|
871
|
+
searchContextFingerprintRef.current = searchFingerprint;
|
|
686
872
|
setStationContextFor('search', {
|
|
687
873
|
title: `Search: ${query}`,
|
|
688
|
-
subtitle: formatSearchSubtitle(stations.length,
|
|
874
|
+
subtitle: formatSearchSubtitle(stations.length, !page.exhausted),
|
|
689
875
|
stations,
|
|
690
876
|
query: query.trim(),
|
|
691
|
-
hasMore:
|
|
877
|
+
hasMore: !page.exhausted, nextCursor: page.nextCursor, sources: page.sources,
|
|
878
|
+
error: discoverySourceWarning(page.sources)
|
|
692
879
|
});
|
|
693
880
|
selectedByScreenRef.current.search = 0;
|
|
694
881
|
lastSubmittedSearchRef.current = query.trim();
|
|
695
|
-
|
|
882
|
+
persistLibrary(() => store.addSearch(query));
|
|
696
883
|
searchHistoryRef.current = { cursor: -1, draft: '' };
|
|
697
|
-
|
|
698
|
-
|
|
884
|
+
if (screenRef.current === 'search') {
|
|
885
|
+
setSelected(0);
|
|
886
|
+
setEditingSearch(true);
|
|
887
|
+
}
|
|
699
888
|
}
|
|
700
889
|
catch (error) {
|
|
701
890
|
if (requestId !== searchRequestRef.current)
|
|
702
891
|
return;
|
|
703
|
-
|
|
892
|
+
if (screenRef.current === 'search')
|
|
893
|
+
setMessage(error instanceof Error ? error.message : 'Search failed.');
|
|
704
894
|
}
|
|
705
895
|
finally {
|
|
706
896
|
if (requestId === searchRequestRef.current)
|
|
707
|
-
|
|
897
|
+
setStationLoading('search', false);
|
|
708
898
|
}
|
|
709
|
-
}, [filters, 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]);
|
|
710
907
|
const loadMoreSearchResults = useCallback(async () => {
|
|
711
908
|
const context = stationContextsRef.current.search;
|
|
712
909
|
const query = context.query;
|
|
713
|
-
if (!query || !context.hasMore ||
|
|
910
|
+
if (!query || !context.hasMore || loadingStationContextsRef.current.search || searchContextFingerprintRef.current !== searchFingerprint) {
|
|
714
911
|
return;
|
|
715
912
|
}
|
|
716
913
|
const offset = context.stations.length;
|
|
717
|
-
const
|
|
914
|
+
const requestId = searchRequestRef.current;
|
|
915
|
+
const requestKey = `${requestId}:${query}:${context.nextCursor ?? offset}:${filters.codec ?? ''}:${filters.language ?? ''}:${filters.minBitrate ?? ''}`;
|
|
718
916
|
if (searchPageRequestRef.current === requestKey) {
|
|
719
917
|
return;
|
|
720
918
|
}
|
|
721
919
|
searchPageRequestRef.current = requestKey;
|
|
722
|
-
|
|
920
|
+
setStationLoading('search', true);
|
|
723
921
|
try {
|
|
724
|
-
const
|
|
922
|
+
const pageOptions = {
|
|
725
923
|
limit: SEARCH_RESULTS_PAGE_SIZE,
|
|
726
|
-
|
|
924
|
+
cursor: context.nextCursor,
|
|
925
|
+
signal: discoveryAbortRef.current.search?.signal,
|
|
727
926
|
codec: filters.codec ?? undefined,
|
|
728
927
|
language: filters.language ?? undefined,
|
|
729
928
|
minBitrate: filters.minBitrate ?? undefined
|
|
730
|
-
}
|
|
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);
|
|
731
933
|
const latest = stationContextsRef.current.search;
|
|
732
|
-
if (latest.query !== query || latest.stations.length !== offset) {
|
|
934
|
+
if (requestId !== searchRequestRef.current || latest.query !== query || latest.stations.length !== offset) {
|
|
733
935
|
return;
|
|
734
936
|
}
|
|
735
|
-
const stations = appendUniqueStations(latest.stations, page);
|
|
736
|
-
const hasMore = page.
|
|
937
|
+
const stations = appendUniqueStations(latest.stations, page.items);
|
|
938
|
+
const hasMore = !page.exhausted;
|
|
737
939
|
setStationContextFor('search', {
|
|
738
940
|
...latest,
|
|
739
941
|
subtitle: formatSearchSubtitle(stations.length, hasMore),
|
|
740
942
|
stations,
|
|
741
|
-
hasMore
|
|
943
|
+
hasMore, nextCursor: page.nextCursor, sources: page.sources,
|
|
944
|
+
error: discoverySourceWarning(page.sources)
|
|
742
945
|
});
|
|
743
946
|
}
|
|
744
947
|
catch (error) {
|
|
745
|
-
if (stationContextsRef.current.search.query === query) {
|
|
948
|
+
if (requestId === searchRequestRef.current && screenRef.current === 'search' && stationContextsRef.current.search.query === query) {
|
|
746
949
|
setMessage(error instanceof Error ? error.message : 'Could not load more search results.');
|
|
747
950
|
}
|
|
748
951
|
}
|
|
749
952
|
finally {
|
|
750
953
|
if (searchPageRequestRef.current === requestKey) {
|
|
751
954
|
searchPageRequestRef.current = null;
|
|
752
|
-
|
|
955
|
+
setStationLoading('search', false);
|
|
753
956
|
}
|
|
754
957
|
}
|
|
755
|
-
}, [filters, providers, setStationContextFor]);
|
|
958
|
+
}, [filters, providers, searchFingerprint, setStationContextFor, setStationLoading]);
|
|
756
959
|
useEffect(() => {
|
|
757
960
|
if (screen === 'search' &&
|
|
758
961
|
stationContexts.search.hasMore &&
|
|
759
|
-
|
|
962
|
+
stationCounts.search - selected <= SEARCH_RESULTS_LOAD_AHEAD) {
|
|
760
963
|
void loadMoreSearchResults();
|
|
761
964
|
}
|
|
762
965
|
}, [
|
|
@@ -764,7 +967,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
764
967
|
screen,
|
|
765
968
|
selected,
|
|
766
969
|
stationContexts.search.hasMore,
|
|
767
|
-
|
|
970
|
+
stationCounts.search,
|
|
971
|
+
stationContexts.search.nextCursor
|
|
768
972
|
]);
|
|
769
973
|
const recallSearchHistory = useCallback((direction) => {
|
|
770
974
|
const history = store.snapshot().searchHistory;
|
|
@@ -789,19 +993,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
789
993
|
}
|
|
790
994
|
}, [searchQuery, store]);
|
|
791
995
|
const loadNearby = useCallback(async () => {
|
|
996
|
+
const signal = beginDiscovery('nearby');
|
|
792
997
|
const requestId = nearbyRequestRef.current + 1;
|
|
793
998
|
nearbyRequestRef.current = requestId;
|
|
794
|
-
|
|
795
|
-
|
|
999
|
+
setStationLoading('nearby', true);
|
|
1000
|
+
if (screenRef.current === 'nearby')
|
|
1001
|
+
setMessage(null);
|
|
796
1002
|
setStationContextFor('nearby', {
|
|
797
1003
|
...stationContextsRef.current.nearby,
|
|
798
1004
|
error: undefined
|
|
799
1005
|
});
|
|
800
|
-
go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
|
|
801
1006
|
try {
|
|
802
1007
|
if (!settingsRef.current.enableNearbyLocation) {
|
|
803
1008
|
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
804
|
-
|
|
1009
|
+
if (screenRef.current === 'nearby')
|
|
1010
|
+
setMessage('Nearby location lookup is off. Showing the last nearby station list.');
|
|
805
1011
|
return;
|
|
806
1012
|
}
|
|
807
1013
|
setStationContextFor('nearby', {
|
|
@@ -812,13 +1018,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
812
1018
|
});
|
|
813
1019
|
return;
|
|
814
1020
|
}
|
|
815
|
-
const detected = location ?? (await providers.detectLocation());
|
|
1021
|
+
const detected = location ?? (await providers.detectLocation({ signal }));
|
|
816
1022
|
if (requestId !== nearbyRequestRef.current)
|
|
817
1023
|
return;
|
|
818
1024
|
setLocation(detected);
|
|
819
1025
|
if (!detected) {
|
|
820
1026
|
if (stationContextsRef.current.nearby.stations.length > 0) {
|
|
821
|
-
|
|
1027
|
+
if (screenRef.current === 'nearby')
|
|
1028
|
+
setMessage('Location detection is unavailable. Showing the last nearby station list.');
|
|
822
1029
|
return;
|
|
823
1030
|
}
|
|
824
1031
|
setStationContextFor('nearby', {
|
|
@@ -829,7 +1036,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
829
1036
|
});
|
|
830
1037
|
return;
|
|
831
1038
|
}
|
|
832
|
-
const stations = await providers.nearby(detected, 90);
|
|
1039
|
+
const stations = await providers.nearby(detected, 90, { signal });
|
|
833
1040
|
if (requestId !== nearbyRequestRef.current)
|
|
834
1041
|
return;
|
|
835
1042
|
setStationContextFor('nearby', {
|
|
@@ -846,17 +1053,20 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
846
1053
|
...stationContextsRef.current.nearby,
|
|
847
1054
|
error: loadError
|
|
848
1055
|
});
|
|
849
|
-
|
|
1056
|
+
if (screenRef.current === 'nearby')
|
|
1057
|
+
setMessage(loadError);
|
|
850
1058
|
}
|
|
851
1059
|
finally {
|
|
852
1060
|
if (requestId === nearbyRequestRef.current)
|
|
853
|
-
|
|
1061
|
+
setStationLoading('nearby', false);
|
|
854
1062
|
}
|
|
855
|
-
}, [
|
|
1063
|
+
}, [beginDiscovery, location, providers, setStationContextFor, setStationLoading]);
|
|
856
1064
|
const queueFromCurrentList = useCallback((station) => {
|
|
857
1065
|
const sourceScreen = screenRef.current;
|
|
858
1066
|
const sourceContextKey = stationContextKeyForScreen(sourceScreen);
|
|
859
|
-
const currentList =
|
|
1067
|
+
const currentList = sourceContextKey
|
|
1068
|
+
? applyStationFilters(stationContextsRef.current[sourceContextKey].stations, filters)
|
|
1069
|
+
: displayStationsRef.current;
|
|
860
1070
|
if (currentList.some(item => stationMatches(item, station))) {
|
|
861
1071
|
return {
|
|
862
1072
|
title: sourceContextKey ? stationContextsRef.current[sourceContextKey].title : 'Current station list',
|
|
@@ -874,7 +1084,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
874
1084
|
sourceContextKey: null,
|
|
875
1085
|
stations: [station]
|
|
876
1086
|
};
|
|
877
|
-
}, [queueContainsStation, stationMatches]);
|
|
1087
|
+
}, [filters, queueContainsStation, stationMatches]);
|
|
878
1088
|
const rememberQueueSelection = useCallback((queue, index) => {
|
|
879
1089
|
if (queue.sourceContextKey) {
|
|
880
1090
|
selectedByScreenRef.current[queue.sourceScreen] = index;
|
|
@@ -885,68 +1095,45 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
885
1095
|
}
|
|
886
1096
|
}, []);
|
|
887
1097
|
const playStation = useCallback(async (station, options = {}) => {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
if (
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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;
|
|
894
1104
|
const queue = options.queue ?? queueFromCurrentList(station);
|
|
895
|
-
setTuningStation(station);
|
|
896
|
-
setNowPlaying(null);
|
|
897
1105
|
try {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
// newer station was selected.
|
|
901
|
-
await player.stop();
|
|
902
|
-
if (requestId !== tuneRequestRef.current)
|
|
903
|
-
return;
|
|
904
|
-
const resolved = await providers.resolve(station);
|
|
905
|
-
if (requestId !== tuneRequestRef.current)
|
|
1106
|
+
await pendingSettings;
|
|
1107
|
+
if (requestId !== tuneRequestRef.current || options.signal?.aborted)
|
|
906
1108
|
return;
|
|
907
|
-
|
|
1109
|
+
desktopSuspendedRef.current = false;
|
|
1110
|
+
playbackQueueRef.current = queue;
|
|
1111
|
+
const tuned = await engine.tune(station, { queue: queue.stations, startPaused: options.startPaused, signal: options.signal });
|
|
908
1112
|
if (requestId !== tuneRequestRef.current)
|
|
909
1113
|
return;
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const nextLibrary = store.addRecent(station);
|
|
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();
|
|
915
1118
|
if (screenRef.current === 'library') {
|
|
916
|
-
const nextLibraryStations = applyStationFilters(buildLibraryStations(
|
|
917
|
-
const
|
|
918
|
-
if (
|
|
919
|
-
selectedByScreenRef.current.library =
|
|
920
|
-
setSelected(
|
|
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);
|
|
921
1124
|
}
|
|
922
1125
|
}
|
|
923
|
-
|
|
924
|
-
if (options.openNowPlaying) {
|
|
1126
|
+
if (options.openNowPlaying)
|
|
925
1127
|
go('now-playing');
|
|
926
|
-
}
|
|
927
1128
|
setMessage(null);
|
|
1129
|
+
return tuned;
|
|
928
1130
|
}
|
|
929
1131
|
catch (error) {
|
|
930
|
-
if (requestId !== tuneRequestRef.current)
|
|
931
|
-
return;
|
|
932
|
-
const message = error instanceof Error ? error.message : 'Could not tune station.';
|
|
933
|
-
const currentList = queue.stations;
|
|
934
|
-
const currentIndex = currentList.findIndex(item => stationKey(item) === stationKey(station));
|
|
935
|
-
const nextStation = currentIndex >= 0 ? currentList[currentIndex + 1] : undefined;
|
|
936
|
-
if (shouldSkipAfterTuneError(error, settingsRef.current.skipBrokenStreams, nextStation)) {
|
|
937
|
-
setMessage(`${message} Skipping to ${nextStation.name}.`);
|
|
938
|
-
setTuningStation(nextStation);
|
|
939
|
-
rememberQueueSelection(queue, currentIndex + 1);
|
|
940
|
-
skipBrokenTimerRef.current = setTimeout(() => {
|
|
941
|
-
skipBrokenTimerRef.current = null;
|
|
942
|
-
playStationRef.current(nextStation, { ...options, queue });
|
|
943
|
-
}, BROKEN_STREAM_AUTO_SKIP_DELAY_MS);
|
|
1132
|
+
if (requestId !== tuneRequestRef.current || options.signal?.aborted || error.name === 'AbortError')
|
|
944
1133
|
return;
|
|
945
|
-
|
|
946
|
-
setTuningStation(null);
|
|
947
|
-
setMessage(message);
|
|
1134
|
+
setMessage(error instanceof Error ? error.message : 'Could not tune station.');
|
|
948
1135
|
}
|
|
949
|
-
}, [filters, go,
|
|
1136
|
+
}, [engine, filters, go, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
|
|
950
1137
|
// Resume the most recent station on launch when opted in, like a radio
|
|
951
1138
|
// powering back on to its last frequency. Runs once and never auto-navigates.
|
|
952
1139
|
const didResumeRef = useRef(false);
|
|
@@ -955,6 +1142,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
955
1142
|
return;
|
|
956
1143
|
}
|
|
957
1144
|
didResumeRef.current = true;
|
|
1145
|
+
if (playbackSession?.status.station || playbackSession?.status.tui?.tuningStation)
|
|
1146
|
+
return;
|
|
958
1147
|
if (!settingsRef.current.resumeOnLaunch) {
|
|
959
1148
|
return;
|
|
960
1149
|
}
|
|
@@ -962,17 +1151,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
962
1151
|
if (last) {
|
|
963
1152
|
void playStation(last);
|
|
964
1153
|
}
|
|
965
|
-
}, [playStation, store]);
|
|
966
|
-
playStationRef.current = playStation;
|
|
1154
|
+
}, [playStation, store, playbackSession]);
|
|
967
1155
|
const cancelPendingAutoSkip = useCallback(() => {
|
|
968
|
-
if (!
|
|
1156
|
+
if (!engine.snapshot().message?.includes('Skipping to'))
|
|
969
1157
|
return;
|
|
970
|
-
|
|
971
|
-
skipBrokenTimerRef.current = null;
|
|
972
|
-
setTuningStation(null);
|
|
1158
|
+
cancelPendingTune();
|
|
973
1159
|
setMessage('Auto-skip canceled. Choose a station and press Enter.');
|
|
974
|
-
}, []);
|
|
975
|
-
const showTransientFooterMessage = useCallback((nextMessage, durationMs =
|
|
1160
|
+
}, [cancelPendingTune, engine]);
|
|
1161
|
+
const showTransientFooterMessage = useCallback((nextMessage, durationMs = TRANSIENT_NOTICE_MS) => {
|
|
976
1162
|
if (transientFooterMessageTimerRef.current) {
|
|
977
1163
|
clearTimeout(transientFooterMessageTimerRef.current);
|
|
978
1164
|
}
|
|
@@ -992,37 +1178,48 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
992
1178
|
showTransientFooterMessage('Ctrl+C again to exit', EXIT_CONFIRMATION_MS);
|
|
993
1179
|
}, [showTransientFooterMessage, shutdown]);
|
|
994
1180
|
const toggleFavorite = useCallback((station) => {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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]);
|
|
1013
1205
|
const showControlResult = useCallback((result) => {
|
|
1014
1206
|
if (!result.ok && result.message) {
|
|
1015
1207
|
setMessage(result.message);
|
|
1016
1208
|
}
|
|
1017
1209
|
}, []);
|
|
1018
|
-
const openStationHomepage = useCallback((station) => {
|
|
1210
|
+
const openStationHomepage = useCallback(async (station) => {
|
|
1019
1211
|
if (!station?.homepage) {
|
|
1020
1212
|
setMessage('This station has no homepage.');
|
|
1021
1213
|
return;
|
|
1022
1214
|
}
|
|
1023
|
-
|
|
1215
|
+
const url = safeExternalHttpUrl(station.homepage);
|
|
1216
|
+
if (!url) {
|
|
1217
|
+
setMessage('This station has no valid HTTP(S) homepage.');
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
setMessage(await openExternal(url)
|
|
1024
1221
|
? `Opening homepage: ${station.name}`
|
|
1025
|
-
:
|
|
1222
|
+
: `Could not open a browser in this session. Homepage: ${sanitizeTerminalText(url) ?? ''}`);
|
|
1026
1223
|
}, []);
|
|
1027
1224
|
const copyStationUrl = useCallback(async (station) => {
|
|
1028
1225
|
if (!station) {
|
|
@@ -1033,23 +1230,29 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1033
1230
|
if (!url) {
|
|
1034
1231
|
url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined);
|
|
1035
1232
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1233
|
+
const safeUrl = url ? safeMediaTarget(url) : null;
|
|
1234
|
+
if (!safeUrl) {
|
|
1235
|
+
setMessage(`No safe stream URL available for ${station.name}.`);
|
|
1038
1236
|
return;
|
|
1039
1237
|
}
|
|
1040
|
-
const copied = copyToClipboard(
|
|
1041
|
-
setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${
|
|
1238
|
+
const copied = await copyToClipboard(safeUrl);
|
|
1239
|
+
setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${sanitizeTerminalText(safeUrl) ?? ''}`);
|
|
1042
1240
|
}, [providers]);
|
|
1043
|
-
const submitAirPlayCode = useCallback((code) => {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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);
|
|
1050
1251
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1252
|
+
catch (error) {
|
|
1253
|
+
reportActionError(error);
|
|
1254
|
+
}
|
|
1255
|
+
}, [go, player, showControlResult, reportActionError]);
|
|
1053
1256
|
const setVolume = useCallback((volume) => {
|
|
1054
1257
|
const clamped = clampVolume(volume);
|
|
1055
1258
|
const requestId = volumeRequestRef.current + 1;
|
|
@@ -1059,8 +1262,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1059
1262
|
updateSettings({ volume: player.getState().volume });
|
|
1060
1263
|
}
|
|
1061
1264
|
showControlResult(result);
|
|
1062
|
-
});
|
|
1063
|
-
}, [player, showControlResult, updateSettings]);
|
|
1265
|
+
}).catch(reportActionError);
|
|
1266
|
+
}, [player, reportActionError, showControlResult, updateSettings]);
|
|
1064
1267
|
const adjustVolume = useCallback((delta) => {
|
|
1065
1268
|
const requestId = volumeRequestRef.current + 1;
|
|
1066
1269
|
volumeRequestRef.current = requestId;
|
|
@@ -1069,62 +1272,66 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1069
1272
|
updateSettings({ volume: player.getState().volume });
|
|
1070
1273
|
}
|
|
1071
1274
|
showControlResult(result);
|
|
1072
|
-
});
|
|
1073
|
-
}, [player, showControlResult, updateSettings]);
|
|
1275
|
+
}).catch(reportActionError);
|
|
1276
|
+
}, [player, reportActionError, showControlResult, updateSettings]);
|
|
1074
1277
|
const toggleMute = useCallback(() => {
|
|
1075
|
-
void player.toggleMute().then(showControlResult);
|
|
1076
|
-
}, [player, showControlResult]);
|
|
1278
|
+
void player.toggleMute().then(showControlResult).catch(reportActionError);
|
|
1279
|
+
}, [player, reportActionError, showControlResult]);
|
|
1077
1280
|
const togglePause = useCallback(() => {
|
|
1078
|
-
void player.togglePause().then(showControlResult);
|
|
1079
|
-
}, [player, showControlResult]);
|
|
1080
|
-
const showTransientMessage = useCallback((nextMessage) => {
|
|
1081
|
-
if (transientMessageTimerRef.current) {
|
|
1082
|
-
clearTimeout(transientMessageTimerRef.current);
|
|
1083
|
-
}
|
|
1084
|
-
setMessage(nextMessage);
|
|
1085
|
-
transientMessageTimerRef.current = setTimeout(() => {
|
|
1086
|
-
setMessage(currentMessage => currentMessage === nextMessage ? null : currentMessage);
|
|
1087
|
-
transientMessageTimerRef.current = null;
|
|
1088
|
-
}, VISUALIZER_MESSAGE_MS);
|
|
1089
|
-
}, []);
|
|
1281
|
+
void player.togglePause().then(showControlResult).catch(reportActionError);
|
|
1282
|
+
}, [player, reportActionError, showControlResult]);
|
|
1090
1283
|
const cycleDisplayColor = useCallback(() => {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
showTransientMessage(`Display color: ${theme}`);
|
|
1284
|
+
void updateSettings(current => ({ theme: nextTheme(current.theme) }))
|
|
1285
|
+
.then(saved => showTransientMessage(`Display color: ${saved.settings.theme}`)).catch(() => undefined);
|
|
1094
1286
|
}, [showTransientMessage, updateSettings]);
|
|
1095
1287
|
const cycleReceiverStyle = useCallback(() => {
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
showTransientMessage(`Receiver style: ${receiverStyle}`);
|
|
1288
|
+
void updateSettings(current => ({ receiverStyle: nextReceiverStyle(current.receiverStyle) }))
|
|
1289
|
+
.then(saved => showTransientMessage(`Receiver style: ${saved.settings.receiverStyle}`)).catch(() => undefined);
|
|
1099
1290
|
}, [showTransientMessage, updateSettings]);
|
|
1100
1291
|
const toggleRadioGarden = useCallback(() => {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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]);
|
|
1106
1298
|
const toggleNearbyLocation = useCallback(() => {
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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]);
|
|
1122
1315
|
const toggleDirectoryVoting = useCallback(() => {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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]);
|
|
1127
1321
|
const refreshAirPlayTargets = useCallback(async (announce = true) => {
|
|
1322
|
+
if (!airPlaySupported) {
|
|
1323
|
+
if (announce)
|
|
1324
|
+
showTransientMessage(airPlayUnavailableMessage);
|
|
1325
|
+
return [];
|
|
1326
|
+
}
|
|
1327
|
+
if (platform === 'linux' && !player.refreshDetectedBackends().includes('airplay')) {
|
|
1328
|
+
setMessage(airPlaySetupRequiredMessage);
|
|
1329
|
+
throw new PlaybackOutputError(airPlaySetupRequiredMessage);
|
|
1330
|
+
}
|
|
1331
|
+
if (networkPolicy().offline) {
|
|
1332
|
+
setMessage('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.');
|
|
1333
|
+
return [];
|
|
1334
|
+
}
|
|
1128
1335
|
try {
|
|
1129
1336
|
const devices = await player.refreshAirPlayDevices();
|
|
1130
1337
|
setAvailableAirPlayDevices(devices);
|
|
@@ -1140,98 +1347,113 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1140
1347
|
}
|
|
1141
1348
|
return devices;
|
|
1142
1349
|
}
|
|
1143
|
-
catch {
|
|
1350
|
+
catch (error) {
|
|
1144
1351
|
setAvailableAirPlayDevices([]);
|
|
1145
|
-
|
|
1146
|
-
setMessage('AirPlay receiver refresh failed.');
|
|
1147
|
-
}
|
|
1148
|
-
return [];
|
|
1352
|
+
throw error instanceof Error ? error : new Error('AirPlay receiver refresh failed.');
|
|
1149
1353
|
}
|
|
1150
|
-
}, [player]);
|
|
1354
|
+
}, [airPlaySupported, airPlayUnavailableMessage, platform, player, showTransientMessage]);
|
|
1151
1355
|
const openAirPlaySettings = useCallback(() => {
|
|
1356
|
+
if (!airPlaySupported) {
|
|
1357
|
+
showTransientMessage(airPlayUnavailableMessage);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1152
1360
|
const preferredIndex = availableAirPlayDevices.findIndex(device => device.id === settingsRef.current.preferredAirPlayDevice);
|
|
1153
1361
|
selectedByScreenRef.current['airplay-settings'] = Math.max(0, preferredIndex);
|
|
1154
1362
|
go('airplay-settings', { resetSelection: false });
|
|
1155
|
-
void refreshAirPlayTargets(false);
|
|
1156
|
-
}, [availableAirPlayDevices, go, refreshAirPlayTargets]);
|
|
1363
|
+
void refreshAirPlayTargets(false).catch(reportActionError);
|
|
1364
|
+
}, [airPlaySupported, airPlayUnavailableMessage, availableAirPlayDevices, go, refreshAirPlayTargets, reportActionError, showTransientMessage]);
|
|
1157
1365
|
const selectAirPlayDeviceAt = useCallback((index) => {
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
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
|
+
}
|
|
1167
1395
|
if (!preferredBackend) {
|
|
1168
|
-
setMessage(
|
|
1396
|
+
setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`);
|
|
1169
1397
|
return;
|
|
1170
1398
|
}
|
|
1171
|
-
updateSettings({ preferredBackend });
|
|
1172
1399
|
if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
|
|
1173
1400
|
const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
|
|
1174
|
-
setMessage(
|
|
1175
|
-
|
|
1401
|
+
setMessage(`Switching audio to AirPlay: ${device.name}...`);
|
|
1402
|
+
await playStation(playingStation, { queue, outputChange: true });
|
|
1176
1403
|
return;
|
|
1177
1404
|
}
|
|
1178
|
-
setMessage(
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
if (!isAirPlayBackendAvailable(availableBackends)) {
|
|
1182
|
-
setMessage(`AirPlay receiver: ${device.name}. AirPlay playback is unavailable; run radiocli doctor.`);
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
updateSettings({ preferredBackend: 'airplay' });
|
|
1186
|
-
if (playingStation && shouldRetuneForAudioOutput(playback.state)) {
|
|
1187
|
-
const queue = playbackQueueRef.current ?? queueFromCurrentList(playingStation);
|
|
1188
|
-
setMessage(`Switching audio to AirPlay: ${device.name}...`);
|
|
1189
|
-
void playStation(playingStation, { queue });
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
setMessage(`AirPlay receiver: ${device.name}. Audio output: AirPlay.`);
|
|
1193
|
-
}, [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]);
|
|
1194
1408
|
const cycleAudioOutput = useCallback(() => {
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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]);
|
|
1217
1441
|
const toggleSkipBrokenStreams = useCallback(() => {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1442
|
+
void updateSettings(current => ({ skipBrokenStreams: !current.skipBrokenStreams }))
|
|
1443
|
+
.then(saved => setMessage(`Skip broken streams ${saved.settings.skipBrokenStreams ? 'enabled' : 'disabled'}.`))
|
|
1444
|
+
.catch(() => undefined);
|
|
1221
1445
|
}, [updateSettings]);
|
|
1222
1446
|
const toggleSetting = useCallback((key) => {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
const next = !current;
|
|
1227
|
-
updateSettings({ [key]: next });
|
|
1228
|
-
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);
|
|
1229
1450
|
}, [updateSettings]);
|
|
1230
1451
|
const toggleAgentSetting = useCallback((key) => {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
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);
|
|
1235
1457
|
}, [updateSettings]);
|
|
1236
1458
|
const setAgentIntegrationEnabled = useCallback(async () => {
|
|
1237
1459
|
if (configuringMcpRef.current) {
|
|
@@ -1239,19 +1461,18 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1239
1461
|
return;
|
|
1240
1462
|
}
|
|
1241
1463
|
configuringMcpRef.current = true;
|
|
1464
|
+
await settingsQueueRef.current;
|
|
1242
1465
|
const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled;
|
|
1243
1466
|
setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...');
|
|
1244
1467
|
try {
|
|
1245
1468
|
const results = await mcpConfigurator(enabled, mcpRuntime(), null);
|
|
1246
1469
|
const failed = results.filter(item => item.status === 'failed');
|
|
1247
|
-
|
|
1248
|
-
updateSettings({ agentControl: { ...agentControl, enabled } });
|
|
1470
|
+
await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled } }));
|
|
1249
1471
|
setMessage(integrationResultMessage(enabled, results, failed));
|
|
1250
1472
|
}
|
|
1251
1473
|
catch (error) {
|
|
1252
1474
|
if (!enabled) {
|
|
1253
|
-
|
|
1254
|
-
updateSettings({ agentControl: { ...agentControl, enabled: false } });
|
|
1475
|
+
await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled: false } }));
|
|
1255
1476
|
}
|
|
1256
1477
|
setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1257
1478
|
}
|
|
@@ -1269,8 +1490,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1269
1490
|
try {
|
|
1270
1491
|
const results = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1271
1492
|
const failed = results.filter(item => item.status === 'failed');
|
|
1272
|
-
|
|
1273
|
-
updateSettings({ agentControl: { ...agentControl, enabled: true } });
|
|
1493
|
+
await updateSettings(current => ({ agentControl: { ...(current.agentControl ?? defaultAgentControlSettings), enabled: true } }));
|
|
1274
1494
|
setMessage(integrationResultMessage(true, results, failed));
|
|
1275
1495
|
}
|
|
1276
1496
|
catch (error) {
|
|
@@ -1284,42 +1504,44 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1284
1504
|
const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
|
|
1285
1505
|
const next = nextSleepTimerMinutes(currentMinutes);
|
|
1286
1506
|
setSleepUntil(next ? Date.now() + next * 60_000 : null);
|
|
1287
|
-
}, [sleepUntil]);
|
|
1507
|
+
}, [sleepUntil, setSleepUntil]);
|
|
1288
1508
|
const openLibrary = useCallback(() => {
|
|
1289
1509
|
go('library', { resetSelection: false });
|
|
1290
1510
|
}, [go]);
|
|
1291
1511
|
const selectedStation = displayStations[selected] ?? null;
|
|
1292
|
-
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]);
|
|
1293
1517
|
const alarmTui = useAlarmTui({
|
|
1294
1518
|
service: alarmService,
|
|
1295
1519
|
store,
|
|
1296
1520
|
library,
|
|
1297
1521
|
setLibrary,
|
|
1298
1522
|
screen,
|
|
1523
|
+
screenRef,
|
|
1299
1524
|
selected,
|
|
1525
|
+
selectedRef,
|
|
1300
1526
|
setSelected,
|
|
1301
1527
|
go,
|
|
1302
1528
|
setMessage,
|
|
1303
1529
|
playingStation,
|
|
1304
|
-
previewStation: alarmPreview ?? (station => playStation(station))
|
|
1530
|
+
previewStation: alarmPreview ?? (async (station) => { await playStation(station); })
|
|
1305
1531
|
});
|
|
1306
1532
|
for (const alarmScreen of ['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']) {
|
|
1307
1533
|
itemCountsRef.current[alarmScreen] = alarmTui.itemCount(alarmScreen) ?? 0;
|
|
1308
1534
|
}
|
|
1309
1535
|
const openScreen = useCallback((target) => {
|
|
1310
1536
|
if (target === 'explore') {
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
}
|
|
1314
|
-
else {
|
|
1537
|
+
go('explore');
|
|
1538
|
+
if (stationContextsRef.current.explore.stations.length === 0 && !loadingStationContextsRef.current.explore) {
|
|
1315
1539
|
void loadExplore();
|
|
1316
1540
|
}
|
|
1317
1541
|
}
|
|
1318
1542
|
else if (target === 'nearby') {
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
}
|
|
1322
|
-
else {
|
|
1543
|
+
go('nearby');
|
|
1544
|
+
if (stationContextsRef.current.nearby.stations.length === 0 && !loadingStationContextsRef.current.nearby) {
|
|
1323
1545
|
void loadNearby();
|
|
1324
1546
|
}
|
|
1325
1547
|
}
|
|
@@ -1338,14 +1560,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1338
1560
|
}
|
|
1339
1561
|
}, [go, loadExplore, loadNearby, openAirPlaySettings, openLibrary]);
|
|
1340
1562
|
const openAdjacentTab = useCallback((direction) => {
|
|
1341
|
-
const active = activeTabForScreen(
|
|
1563
|
+
const active = activeTabForScreen(screenRef.current);
|
|
1342
1564
|
const currentIndex = topTabs.findIndex(tab => tab.screen === active);
|
|
1343
1565
|
const nextIndex = (currentIndex + direction + topTabs.length) % topTabs.length;
|
|
1344
1566
|
const next = topTabs[nextIndex];
|
|
1345
1567
|
if (next) {
|
|
1346
1568
|
openScreen(next.screen);
|
|
1347
1569
|
}
|
|
1348
|
-
}, [openScreen
|
|
1570
|
+
}, [openScreen]);
|
|
1349
1571
|
const handleUpdateCommand = useCallback(async () => {
|
|
1350
1572
|
const updateCheck = library.updateCheck;
|
|
1351
1573
|
if (!updateCheck || shouldCheckForUpdate(updateCheck)) {
|
|
@@ -1357,7 +1579,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1357
1579
|
}
|
|
1358
1580
|
}
|
|
1359
1581
|
const command = updateCommandForInstall();
|
|
1360
|
-
const copied = copyToClipboard(command.command);
|
|
1582
|
+
const copied = await copyToClipboard(command.command);
|
|
1361
1583
|
const latestVersion = store.snapshot().updateCheck?.latestVersion ?? updateCheck?.latestVersion;
|
|
1362
1584
|
const prefix = latestVersion ? `Latest v${latestVersion}. ` : '';
|
|
1363
1585
|
const method = command.method === 'homebrew' ? 'Homebrew' : command.method === 'npm' ? 'npm' : 'your install method';
|
|
@@ -1394,14 +1616,14 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1394
1616
|
try {
|
|
1395
1617
|
const repaired = await mcpConfigurator(true, mcpRuntime(), null);
|
|
1396
1618
|
const suffix = repaired.some(item => item.status === 'failed') ? ' Some MCP clients need manual repair.' : ' MCP integrations repaired.';
|
|
1397
|
-
setMessage(`Update installed.${suffix}
|
|
1619
|
+
setMessage(`Update installed.${suffix} Quit and reopen RadioCLI; restart MCP clients to load updated tools.`);
|
|
1398
1620
|
}
|
|
1399
1621
|
catch {
|
|
1400
|
-
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.');
|
|
1401
1623
|
}
|
|
1402
1624
|
}
|
|
1403
1625
|
else {
|
|
1404
|
-
setMessage('Update installed.
|
|
1626
|
+
setMessage('Update installed. Quit and reopen RadioCLI to use the new version.');
|
|
1405
1627
|
}
|
|
1406
1628
|
return;
|
|
1407
1629
|
}
|
|
@@ -1410,17 +1632,21 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1410
1632
|
}, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
|
|
1411
1633
|
const executeCommand = useCommandExecutor({
|
|
1412
1634
|
beginLearningTransportKey,
|
|
1635
|
+
cancelPendingTune,
|
|
1413
1636
|
countries,
|
|
1414
1637
|
go,
|
|
1415
1638
|
loadCountry,
|
|
1416
1639
|
openAirPlaySettings,
|
|
1640
|
+
openAirPlayCode,
|
|
1417
1641
|
openLibrary,
|
|
1642
|
+
persistLibrary,
|
|
1418
1643
|
player,
|
|
1419
1644
|
playingStation,
|
|
1420
1645
|
providers,
|
|
1421
1646
|
resetLearnedTransportKeys,
|
|
1422
1647
|
runSearch,
|
|
1423
1648
|
screen,
|
|
1649
|
+
screenRef,
|
|
1424
1650
|
selectedStation,
|
|
1425
1651
|
setCountries,
|
|
1426
1652
|
setFilters,
|
|
@@ -1459,11 +1685,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1459
1685
|
void playStation(nextStation, { queue });
|
|
1460
1686
|
}
|
|
1461
1687
|
}, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
|
|
1462
|
-
agentHandlerRef.current = async (command) => {
|
|
1688
|
+
agentHandlerRef.current = async (command, signal) => {
|
|
1463
1689
|
const sessionStatus = () => ({
|
|
1464
1690
|
owner: 'tui',
|
|
1465
1691
|
playback: player.getState(),
|
|
1466
1692
|
station: playingStationRef.current,
|
|
1693
|
+
nowPlaying: engine.snapshot().nowPlaying,
|
|
1694
|
+
persistenceWarning: persistenceWarning ?? engine.snapshot().warning,
|
|
1467
1695
|
queue: playbackQueueRef.current?.stations ?? [],
|
|
1468
1696
|
output: {
|
|
1469
1697
|
preferredBackend: settingsRef.current.preferredBackend,
|
|
@@ -1471,6 +1699,13 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1471
1699
|
}
|
|
1472
1700
|
});
|
|
1473
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
|
+
}
|
|
1706
|
+
if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !airPlaySupported) {
|
|
1707
|
+
return respond(airPlayUnavailableMessage, false, command.type === 'airplay-list' ? [] : undefined);
|
|
1708
|
+
}
|
|
1474
1709
|
if (command.type === 'status')
|
|
1475
1710
|
return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
|
|
1476
1711
|
if (command.type === 'play') {
|
|
@@ -1478,28 +1713,37 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1478
1713
|
return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`);
|
|
1479
1714
|
}
|
|
1480
1715
|
const queue = { title: 'Agent selection', sourceScreen: 'now-playing', sourceContextKey: null, stations: command.queue?.length ? command.queue : [command.station] };
|
|
1481
|
-
await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying });
|
|
1716
|
+
const tuned = await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying, signal });
|
|
1482
1717
|
const state = player.getState();
|
|
1483
|
-
const playingRequestedStation = playingStationRef.current
|
|
1484
|
-
&& stationKey(playingStationRef.current) === stationKey(
|
|
1718
|
+
const playingRequestedStation = tuned && playingStationRef.current
|
|
1719
|
+
&& stationKey(playingStationRef.current) === stationKey(tuned)
|
|
1485
1720
|
&& state.state === 'playing'
|
|
1486
1721
|
&& state.ready;
|
|
1487
1722
|
return playingRequestedStation
|
|
1488
|
-
? respond(`Playing ${
|
|
1723
|
+
? respond(`Playing ${tuned.name}.`)
|
|
1489
1724
|
: respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false);
|
|
1490
1725
|
}
|
|
1491
1726
|
if (command.type === 'pause') {
|
|
1492
|
-
const control = await
|
|
1727
|
+
const control = await engine.pause();
|
|
1493
1728
|
return respond(control.message ?? 'Paused.', control.ok);
|
|
1494
1729
|
}
|
|
1495
1730
|
if (command.type === 'resume') {
|
|
1496
|
-
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();
|
|
1497
1737
|
return respond(control.message ?? 'Resumed.', control.ok);
|
|
1498
1738
|
}
|
|
1499
1739
|
if (command.type === 'stop') {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
+
}
|
|
1503
1747
|
playingStationRef.current = null;
|
|
1504
1748
|
setPlayingStation(null);
|
|
1505
1749
|
setTuningStation(null);
|
|
@@ -1507,9 +1751,10 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1507
1751
|
return respond('Stopped RadioCLI.');
|
|
1508
1752
|
}
|
|
1509
1753
|
if (command.type === 'alarm-preempt') {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1754
|
+
desktopSuspendedRef.current = true;
|
|
1755
|
+
desktopMediaRef.current?.refresh();
|
|
1756
|
+
cancelPendingTune();
|
|
1757
|
+
await engine.stop();
|
|
1513
1758
|
playingStationRef.current = null;
|
|
1514
1759
|
setPlayingStation(null);
|
|
1515
1760
|
setTuningStation(null);
|
|
@@ -1517,9 +1762,16 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1517
1762
|
return respond('Interactive playback yielded to the alarm.');
|
|
1518
1763
|
}
|
|
1519
1764
|
if (command.type === 'set-volume') {
|
|
1520
|
-
const control =
|
|
1765
|
+
const control = command.unmute
|
|
1766
|
+
? await player.setVolume(clampVolume(command.volume), { allowStopped: true })
|
|
1767
|
+
: await player.setVolume(clampVolume(command.volume));
|
|
1521
1768
|
if (control.ok)
|
|
1522
|
-
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
|
+
}
|
|
1523
1775
|
return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
|
|
1524
1776
|
}
|
|
1525
1777
|
if (command.type === 'set-muted') {
|
|
@@ -1532,15 +1784,36 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1532
1784
|
return respond('No active station to favorite.', false);
|
|
1533
1785
|
const current = store.isFavorite(station);
|
|
1534
1786
|
if (current !== command.favorite)
|
|
1535
|
-
toggleFavorite(station);
|
|
1787
|
+
await toggleFavorite(station);
|
|
1536
1788
|
return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
|
|
1537
1789
|
}
|
|
1538
1790
|
if (command.type === 'airplay-list') {
|
|
1539
|
-
|
|
1791
|
+
if (networkPolicy().offline)
|
|
1792
|
+
return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
|
|
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
|
+
}
|
|
1540
1802
|
return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
|
|
1541
1803
|
}
|
|
1542
1804
|
if (command.type === 'airplay-select') {
|
|
1543
|
-
|
|
1805
|
+
if (networkPolicy().offline)
|
|
1806
|
+
return respond('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false);
|
|
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();
|
|
1544
1817
|
const device = devices.find(item => item.id === command.deviceId);
|
|
1545
1818
|
if (!device)
|
|
1546
1819
|
return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
|
|
@@ -1548,18 +1821,19 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1548
1821
|
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1549
1822
|
if (!backend)
|
|
1550
1823
|
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1551
|
-
updateSettings({ preferredBackend: backend });
|
|
1824
|
+
await updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: backend });
|
|
1825
|
+
signal?.throwIfAborted();
|
|
1552
1826
|
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1553
|
-
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1827
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
|
|
1554
1828
|
}
|
|
1555
1829
|
return respond(`Audio output set to this device (${backend}).`);
|
|
1556
1830
|
}
|
|
1557
1831
|
if (!isAirPlayBackendAvailable(availableBackends))
|
|
1558
1832
|
return respond('AirPlay playback is unavailable; run radiocli doctor.', false);
|
|
1559
|
-
updateSettings({ preferredAirPlayDevice: device.id });
|
|
1560
|
-
|
|
1833
|
+
await updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: 'airplay' });
|
|
1834
|
+
signal?.throwIfAborted();
|
|
1561
1835
|
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1562
|
-
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1836
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
|
|
1563
1837
|
}
|
|
1564
1838
|
return respond(`Audio output set to AirPlay receiver ${device.name}.`);
|
|
1565
1839
|
}
|
|
@@ -1567,20 +1841,26 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1567
1841
|
const backend = preferredLocalPlaybackBackend(availableBackends);
|
|
1568
1842
|
if (!backend)
|
|
1569
1843
|
return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
1570
|
-
updateSettings({ preferredBackend: backend });
|
|
1844
|
+
await updateSettings({ preferredBackend: backend });
|
|
1845
|
+
signal?.throwIfAborted();
|
|
1571
1846
|
if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
|
|
1572
|
-
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
|
|
1847
|
+
await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current), signal });
|
|
1573
1848
|
}
|
|
1574
1849
|
return respond(`Audio output set to this device (${backend}).`);
|
|
1575
1850
|
}
|
|
1576
1851
|
if (command.type === 'airplay-passcode') {
|
|
1577
|
-
const control = player.submitAirPlayPasscode(command.code);
|
|
1852
|
+
const control = await player.submitAirPlayPasscode(command.code);
|
|
1578
1853
|
return respond(control.message ?? 'AirPlay code sent.', control.ok);
|
|
1579
1854
|
}
|
|
1580
1855
|
if (command.type === 'update-settings') {
|
|
1581
|
-
|
|
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);
|
|
1582
1859
|
return respond('RadioCLI settings updated.');
|
|
1583
1860
|
}
|
|
1861
|
+
if (command.type !== 'next' && command.type !== 'previous')
|
|
1862
|
+
throw new Error('Unsupported radio command.');
|
|
1863
|
+
signal?.throwIfAborted();
|
|
1584
1864
|
const active = playingStationRef.current;
|
|
1585
1865
|
const queue = playbackQueueRef.current;
|
|
1586
1866
|
if (!active || !queue?.stations.length)
|
|
@@ -1590,33 +1870,80 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1590
1870
|
const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length];
|
|
1591
1871
|
if (!next)
|
|
1592
1872
|
return respond('No adjacent station is available.', false);
|
|
1593
|
-
|
|
1594
|
-
|
|
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));
|
|
1595
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]);
|
|
1596
1902
|
useEffect(() => {
|
|
1597
|
-
if (!stdin.isTTY
|
|
1903
|
+
if (!stdin.isTTY)
|
|
1598
1904
|
return;
|
|
1905
|
+
if (playbackSession) {
|
|
1906
|
+
if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled) {
|
|
1907
|
+
void playbackSession.call(initialAgentCommand).catch(reportActionError);
|
|
1908
|
+
}
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1599
1911
|
let disposed = false;
|
|
1600
1912
|
let close;
|
|
1601
|
-
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) => {
|
|
1602
1926
|
if (disposed) {
|
|
1603
1927
|
await session.close();
|
|
1604
1928
|
return;
|
|
1605
1929
|
}
|
|
1606
1930
|
close = session.close;
|
|
1607
|
-
|
|
1608
|
-
|
|
1931
|
+
setControlSessionReady(true);
|
|
1932
|
+
if (initialAgentCommand && (settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
|
|
1933
|
+
await dispatchOwnerCommand(initialAgentCommand);
|
|
1609
1934
|
}).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.'));
|
|
1610
1935
|
return () => {
|
|
1611
1936
|
disposed = true;
|
|
1937
|
+
setControlSessionReady(false);
|
|
1612
1938
|
if (close)
|
|
1613
1939
|
void close();
|
|
1614
1940
|
};
|
|
1615
|
-
}, [initialAgentCommand,
|
|
1941
|
+
}, [dispatchOwnerCommand, initialAgentCommand, stdin, store, playbackSession, reportActionError]);
|
|
1616
1942
|
useAppInput({
|
|
1617
1943
|
adjustVolume,
|
|
1618
1944
|
airPlayCode,
|
|
1619
1945
|
canEnterAirPlayCode,
|
|
1946
|
+
airPlaySupported,
|
|
1620
1947
|
cancelPendingAutoSkip,
|
|
1621
1948
|
copyStationUrl,
|
|
1622
1949
|
openStationHomepage,
|
|
@@ -1656,7 +1983,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1656
1983
|
moveExploreCursorToCell: moveExploreMapCursorToCell,
|
|
1657
1984
|
refreshProviderHealth,
|
|
1658
1985
|
refreshAirPlayTargets: () => {
|
|
1659
|
-
void refreshAirPlayTargets();
|
|
1986
|
+
void refreshAirPlayTargets().catch(reportActionError);
|
|
1660
1987
|
},
|
|
1661
1988
|
resetLearnedTransportKeys,
|
|
1662
1989
|
repairMcpIntegrations,
|
|
@@ -1664,7 +1991,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1664
1991
|
runSearch,
|
|
1665
1992
|
saveLearnedTransportKey,
|
|
1666
1993
|
screen,
|
|
1667
|
-
|
|
1994
|
+
screenRef,
|
|
1995
|
+
settingsPageRef,
|
|
1668
1996
|
searchQuery,
|
|
1669
1997
|
selectedRef,
|
|
1670
1998
|
selectedStationForInput,
|
|
@@ -1711,6 +2039,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1711
2039
|
spinnerFrame
|
|
1712
2040
|
});
|
|
1713
2041
|
const basePageFooter = pageFooterText({
|
|
2042
|
+
airPlaySupported,
|
|
1714
2043
|
canEnterAirPlayCode,
|
|
1715
2044
|
capturingTransportAction,
|
|
1716
2045
|
commandMode,
|
|
@@ -1730,7 +2059,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1730
2059
|
(screen === 'search' && editingSearch) ||
|
|
1731
2060
|
screen === 'airplay-code');
|
|
1732
2061
|
const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
|
|
1733
|
-
const
|
|
2062
|
+
const statusMessage = message ?? persistenceWarning ?? presenceWarning ?? (screen === 'search' ? stationContext.error ?? null : null);
|
|
2063
|
+
const microFooter = footerMessage ?? statusMessage ?? (pageFooterOwnsCompactRow
|
|
1734
2064
|
? pageFooter
|
|
1735
2065
|
: footerPlayback.state === 'loading' && playbackFooter
|
|
1736
2066
|
? playbackFooter
|
|
@@ -1739,17 +2069,17 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
|
|
|
1739
2069
|
: pageFooter);
|
|
1740
2070
|
const compactGlobalFooter = '←/→ tabs · ? help · q quit';
|
|
1741
2071
|
const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
|
|
1742
|
-
const fullStatusRows = fullStatusFooterRows(screen,
|
|
1743
|
-
const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
|
|
1744
|
-
const compactStatus = footerMessage ??
|
|
2072
|
+
const fullStatusRows = fullStatusFooterRows(screen, statusMessage, footerMessage, playbackFooter);
|
|
2073
|
+
const fullLegendRows = useMemo(() => balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve), [pageFooter, globalFooter, frameWidth, versionReserve]);
|
|
2074
|
+
const compactStatus = footerMessage ?? statusMessage ?? playbackFooter;
|
|
1745
2075
|
const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
|
|
1746
|
-
const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
|
|
2076
|
+
const compactLegendRows = useMemo(() => balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve), [pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve]);
|
|
1747
2077
|
const microLegendWidth = Math.max(1, frameWidth - versionReserve);
|
|
1748
|
-
const microLegend = commandMode || capturingTransportAction || footerMessage ||
|
|
2078
|
+
const microLegend = commandMode || capturingTransportAction || footerMessage || statusMessage
|
|
1749
2079
|
? microFooter
|
|
1750
2080
|
: microShortcutFooterText(microFooter, microLegendWidth);
|
|
1751
2081
|
const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
|
|
1752
|
-
return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion:
|
|
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}`))] }) })) })] }) }) }));
|
|
1753
2083
|
}
|
|
1754
2084
|
function mcpRuntime() {
|
|
1755
2085
|
return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
|
|
@@ -1777,12 +2107,12 @@ function buildLibraryStations(library) {
|
|
|
1777
2107
|
for (const station of library.favorites) {
|
|
1778
2108
|
addStation(station);
|
|
1779
2109
|
}
|
|
1780
|
-
for (const item of library.recent) {
|
|
1781
|
-
addStation(item.station);
|
|
1782
|
-
}
|
|
1783
2110
|
for (const station of library.imported) {
|
|
1784
2111
|
addStation(station);
|
|
1785
2112
|
}
|
|
2113
|
+
for (const item of library.recent) {
|
|
2114
|
+
addStation(item.station);
|
|
2115
|
+
}
|
|
1786
2116
|
return stations;
|
|
1787
2117
|
}
|
|
1788
2118
|
function exploreCursorLocation(cursor) {
|
|
@@ -1804,6 +2134,10 @@ function appendUniqueStations(current, page) {
|
|
|
1804
2134
|
}
|
|
1805
2135
|
return stations;
|
|
1806
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
|
+
}
|
|
1807
2141
|
function formatCountryStationsSubtitle(country, loaded, hasMore) {
|
|
1808
2142
|
const total = country.stationCount.toLocaleString();
|
|
1809
2143
|
const loadedLabel = loaded.toLocaleString();
|
|
@@ -1880,7 +2214,7 @@ function audioOutputSwitchLabel(output, backends) {
|
|
|
1880
2214
|
return audioOutputLabel(output);
|
|
1881
2215
|
}
|
|
1882
2216
|
function librarySubtitle(library) {
|
|
1883
|
-
return `${library.favorites.length} favorites · ${library.
|
|
2217
|
+
return `${library.favorites.length} favorites · ${library.imported.length} imported · ${library.recent.length} recent · new imports before recents`;
|
|
1884
2218
|
}
|
|
1885
2219
|
function mouseVisibleRows(screen, layout) {
|
|
1886
2220
|
if (screen === 'help')
|
|
@@ -1907,3 +2241,10 @@ function mouseVisibleRows(screen, layout) {
|
|
|
1907
2241
|
return layout.stationRows;
|
|
1908
2242
|
return layout.contentRows;
|
|
1909
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
|
+
}
|