@ciphore/radiocli 0.1.5 → 0.1.7
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 +39 -0
- package/README.md +7 -5
- package/dist/cli.js +6 -0
- package/dist/player/backend-install.js +23 -0
- package/dist/player/command.js +92 -4
- package/dist/player/player-controller.js +26 -4
- package/dist/providers/provider-manager.js +3 -0
- package/dist/providers/radio-browser.js +18 -2
- package/dist/storage/store.js +50 -2
- package/dist/ui/App.js +115 -13
- package/dist/ui/AppContent.js +5 -1
- package/dist/ui/app-state.js +10 -1
- package/dist/ui/ascii.js +84 -0
- package/dist/ui/audio-output.js +4 -1
- package/dist/ui/components/ScreenHeader.js +6 -2
- package/dist/ui/components/TopTabs.js +8 -3
- package/dist/ui/display-context.js +25 -0
- package/dist/ui/help-content.js +116 -0
- package/dist/ui/page-footer.js +4 -1
- package/dist/ui/screen-items.js +4 -0
- package/dist/ui/screens/ExploreScreen.js +7 -3
- package/dist/ui/screens/HelpScreen.js +12 -0
- package/dist/ui/screens/MapScreen.js +7 -2
- package/dist/ui/screens/NowPlayingScreen.js +27 -4
- package/dist/ui/screens/SearchScreen.js +4 -2
- package/dist/ui/screens/SettingsScreen.js +8 -0
- package/dist/ui/screens/StatsScreen.js +25 -11
- package/dist/ui/screens/screen-render.test.js +152 -0
- package/dist/ui/system-actions.js +58 -0
- package/dist/ui/use-app-input.js +48 -2
- package/dist/ui/use-command-executor.js +22 -0
- package/dist/version.js +20 -0
- package/package.json +2 -1
package/dist/ui/App.js
CHANGED
|
@@ -6,7 +6,8 @@ import { PlayerController } from '../player/player-controller.js';
|
|
|
6
6
|
import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
|
|
7
7
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
8
8
|
import { receiverStyleNames } from '../types.js';
|
|
9
|
-
import {
|
|
9
|
+
import { nextReceiverStyle, nextTheme, textDim, themeAccent } from './theme.js';
|
|
10
|
+
import { DisplayContext, resolveDisplayMode } from './display-context.js';
|
|
10
11
|
import { homeItems, settingsItems } from './screen-items.js';
|
|
11
12
|
import { AppContent } from './AppContent.js';
|
|
12
13
|
import { TopTabs } from './components/TopTabs.js';
|
|
@@ -20,11 +21,18 @@ import { useCommandExecutor } from './use-command-executor.js';
|
|
|
20
21
|
import { isAirPlayCodePromptActive } from './screens/AirPlayCodeScreen.js';
|
|
21
22
|
import { isAirPlayBackendAvailable } from './airplay-settings.js';
|
|
22
23
|
import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
|
|
24
|
+
import { copyToClipboard, openExternal } from './system-actions.js';
|
|
23
25
|
import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
|
|
24
26
|
const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
|
|
25
27
|
const LIVE_RECEIVER_PULSE_MS = 80;
|
|
26
28
|
const AMBIENT_RECEIVER_PULSE_MS = 140;
|
|
27
29
|
const LOADING_SPINNER_MS = 120;
|
|
30
|
+
const settingToggleLabel = {
|
|
31
|
+
resumeOnLaunch: 'Resume on launch',
|
|
32
|
+
transparentBackground: 'Transparent background',
|
|
33
|
+
asciiMode: 'ASCII-safe display',
|
|
34
|
+
reduceMotion: 'Reduce motion'
|
|
35
|
+
};
|
|
28
36
|
export function App({ store: providedStore, providers: providedProviders }) {
|
|
29
37
|
const { exit } = useApp();
|
|
30
38
|
const { stdin } = useStdin();
|
|
@@ -74,10 +82,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
74
82
|
const stationContextsRef = useRef(stationContexts);
|
|
75
83
|
const lastStationContextKeyRef = useRef('explore');
|
|
76
84
|
const lastSubmittedSearchRef = useRef('');
|
|
85
|
+
const searchHistoryRef = useRef({ cursor: -1, draft: '' });
|
|
77
86
|
const exploreCursorRef = useRef(exploreCursor);
|
|
78
87
|
const exploreRequestRef = useRef(0);
|
|
79
88
|
const exploreMoveTimerRef = useRef(null);
|
|
80
89
|
const theme = library.settings.theme;
|
|
90
|
+
const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
|
|
81
91
|
const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
|
|
82
92
|
const diagnostics = player.diagnostics();
|
|
83
93
|
const filterLabel = formatFilterLabel(filters);
|
|
@@ -124,7 +134,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
124
134
|
stats: 1,
|
|
125
135
|
'airplay-settings': 0,
|
|
126
136
|
'airplay-code': 1,
|
|
127
|
-
settings: settingsItems.length
|
|
137
|
+
settings: settingsItems.length,
|
|
138
|
+
help: 0
|
|
128
139
|
});
|
|
129
140
|
itemCountsRef.current = {
|
|
130
141
|
home: homeItems.length,
|
|
@@ -139,7 +150,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
139
150
|
stats: 1,
|
|
140
151
|
'airplay-settings': availableAirPlayDevices.length,
|
|
141
152
|
'airplay-code': 1,
|
|
142
|
-
settings: settingsItems.length
|
|
153
|
+
settings: settingsItems.length,
|
|
154
|
+
help: 0
|
|
143
155
|
};
|
|
144
156
|
const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
|
|
145
157
|
displayStationsRef.current = displayStations;
|
|
@@ -151,7 +163,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
151
163
|
const layout = computeTerminalLayout(columns, rows, showPlaybackFooter ? 3 : 2);
|
|
152
164
|
const frameWidth = Math.max(40, layout.columns - 2);
|
|
153
165
|
useEffect(() => player.onChange(setPlayback), [player]);
|
|
154
|
-
|
|
166
|
+
const playingStationRef = useRef(null);
|
|
167
|
+
playingStationRef.current = playingStation;
|
|
168
|
+
useEffect(() => player.onMetadata(metadata => {
|
|
169
|
+
setNowPlaying(metadata);
|
|
170
|
+
const station = playingStationRef.current;
|
|
171
|
+
if (station && metadata.title) {
|
|
172
|
+
setLibrary(store.recordTrack(station, metadata.title));
|
|
173
|
+
}
|
|
174
|
+
}), [player, store]);
|
|
155
175
|
useEffect(() => {
|
|
156
176
|
if (screen !== 'explore' || layout.compact || !stdout.isTTY) {
|
|
157
177
|
return;
|
|
@@ -169,6 +189,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
169
189
|
}, [renderedStationContextKey, screen, selected]);
|
|
170
190
|
useEffect(() => {
|
|
171
191
|
if (!shouldAnimateReceiver(screen, playback) ||
|
|
192
|
+
library.settings.reduceMotion ||
|
|
172
193
|
process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
|
|
173
194
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
174
195
|
return;
|
|
@@ -176,9 +197,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
176
197
|
const intervalMs = LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle) ? LIVE_RECEIVER_PULSE_MS : AMBIENT_RECEIVER_PULSE_MS;
|
|
177
198
|
const timer = setInterval(() => setPulse(value => (value + 1) % 240), intervalMs);
|
|
178
199
|
return () => clearInterval(timer);
|
|
179
|
-
}, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
|
|
200
|
+
}, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
|
|
180
201
|
useEffect(() => {
|
|
181
202
|
if (playback.state !== 'loading' ||
|
|
203
|
+
library.settings.reduceMotion ||
|
|
182
204
|
process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
|
|
183
205
|
process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
|
|
184
206
|
setSpinnerFrame(0);
|
|
@@ -186,7 +208,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
186
208
|
}
|
|
187
209
|
const timer = setInterval(() => setSpinnerFrame(value => (value + 1) % 1000), LOADING_SPINNER_MS);
|
|
188
210
|
return () => clearInterval(timer);
|
|
189
|
-
}, [playback.state]);
|
|
211
|
+
}, [library.settings.reduceMotion, playback.state]);
|
|
190
212
|
useEffect(() => {
|
|
191
213
|
if ((screen === 'countries' || screen === 'map') && countries.length === 0 && !loadingCountries) {
|
|
192
214
|
setLoadingCountries(true);
|
|
@@ -420,6 +442,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
420
442
|
});
|
|
421
443
|
selectedByScreenRef.current.search = 0;
|
|
422
444
|
lastSubmittedSearchRef.current = query.trim();
|
|
445
|
+
setLibrary(store.addSearch(query));
|
|
446
|
+
searchHistoryRef.current = { cursor: -1, draft: '' };
|
|
423
447
|
setSelected(0);
|
|
424
448
|
setEditingSearch(true);
|
|
425
449
|
}
|
|
@@ -429,7 +453,29 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
429
453
|
finally {
|
|
430
454
|
setLoadingStations(false);
|
|
431
455
|
}
|
|
432
|
-
}, [filters, providers, searchQuery, setStationContextFor]);
|
|
456
|
+
}, [filters, providers, searchQuery, setStationContextFor, store]);
|
|
457
|
+
const recallSearchHistory = useCallback((direction) => {
|
|
458
|
+
const history = store.snapshot().searchHistory;
|
|
459
|
+
if (history.length === 0) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const state = searchHistoryRef.current;
|
|
463
|
+
if (direction === 'older') {
|
|
464
|
+
if (state.cursor === -1) {
|
|
465
|
+
state.draft = searchQuery;
|
|
466
|
+
}
|
|
467
|
+
state.cursor = Math.min(history.length - 1, state.cursor + 1);
|
|
468
|
+
setSearchQuery(history[state.cursor] ?? '');
|
|
469
|
+
}
|
|
470
|
+
else if (state.cursor <= 0) {
|
|
471
|
+
state.cursor = -1;
|
|
472
|
+
setSearchQuery(state.draft);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
state.cursor -= 1;
|
|
476
|
+
setSearchQuery(history[state.cursor] ?? '');
|
|
477
|
+
}
|
|
478
|
+
}, [searchQuery, store]);
|
|
433
479
|
const loadNearby = useCallback(async () => {
|
|
434
480
|
setLoadingStations(true);
|
|
435
481
|
setMessage(null);
|
|
@@ -536,6 +582,22 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
536
582
|
setMessage(message);
|
|
537
583
|
}
|
|
538
584
|
}, [filters, go, player, providers, queueFromCurrentList, rememberQueueSelection, stationMatches, store]);
|
|
585
|
+
// Resume the most recent station on launch when opted in, like a radio
|
|
586
|
+
// powering back on to its last frequency. Runs once and never auto-navigates.
|
|
587
|
+
const didResumeRef = useRef(false);
|
|
588
|
+
useEffect(() => {
|
|
589
|
+
if (didResumeRef.current) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
didResumeRef.current = true;
|
|
593
|
+
if (!settingsRef.current.resumeOnLaunch) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const last = store.snapshot().recent[0]?.station;
|
|
597
|
+
if (last) {
|
|
598
|
+
void playStation(last);
|
|
599
|
+
}
|
|
600
|
+
}, [playStation, store]);
|
|
539
601
|
playStationRef.current = playStation;
|
|
540
602
|
const toggleFavorite = useCallback((station) => {
|
|
541
603
|
if (!station) {
|
|
@@ -545,12 +607,40 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
545
607
|
const wasFavorite = store.isFavorite(station);
|
|
546
608
|
setLibrary(store.toggleFavorite(station));
|
|
547
609
|
setMessage(`${wasFavorite ? 'Removed from' : 'Added to'} favorites: ${station.name}`);
|
|
548
|
-
|
|
610
|
+
if (!wasFavorite) {
|
|
611
|
+
// Best-effort upvote back to the directory; never blocks favoriting.
|
|
612
|
+
void providers.vote(station);
|
|
613
|
+
}
|
|
614
|
+
}, [providers, store]);
|
|
549
615
|
const showControlResult = useCallback((result) => {
|
|
550
616
|
if (!result.ok && result.message) {
|
|
551
617
|
setMessage(result.message);
|
|
552
618
|
}
|
|
553
619
|
}, []);
|
|
620
|
+
const openStationHomepage = useCallback((station) => {
|
|
621
|
+
if (!station?.homepage) {
|
|
622
|
+
setMessage('This station has no homepage.');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
openExternal(station.homepage);
|
|
626
|
+
setMessage(`Opening homepage: ${station.name}`);
|
|
627
|
+
}, []);
|
|
628
|
+
const copyStationUrl = useCallback(async (station) => {
|
|
629
|
+
if (!station) {
|
|
630
|
+
setMessage('Select or play a station first.');
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
let url = station.streamUrl;
|
|
634
|
+
if (!url) {
|
|
635
|
+
url = await providers.resolve(station).then(resolved => resolved.url).catch(() => undefined);
|
|
636
|
+
}
|
|
637
|
+
if (!url) {
|
|
638
|
+
setMessage(`No stream URL available for ${station.name}.`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const copied = copyToClipboard(url);
|
|
642
|
+
setMessage(copied ? `Copied stream URL: ${station.name}` : `Stream URL: ${url}`);
|
|
643
|
+
}, [providers]);
|
|
554
644
|
const submitAirPlayCode = useCallback((code) => {
|
|
555
645
|
const result = player.submitAirPlayPasscode(code);
|
|
556
646
|
if (result.ok) {
|
|
@@ -695,6 +785,11 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
695
785
|
updateSettings({ skipBrokenStreams });
|
|
696
786
|
setMessage(`Skip broken streams ${skipBrokenStreams ? 'enabled' : 'disabled'}.`);
|
|
697
787
|
}, [updateSettings]);
|
|
788
|
+
const toggleSetting = useCallback((key) => {
|
|
789
|
+
const next = !settingsRef.current[key];
|
|
790
|
+
updateSettings({ [key]: next });
|
|
791
|
+
setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
|
|
792
|
+
}, [updateSettings]);
|
|
698
793
|
const cycleSleepTimer = useCallback(() => {
|
|
699
794
|
const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
|
|
700
795
|
const next = nextSleepTimerMinutes(currentMinutes);
|
|
@@ -798,6 +893,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
798
893
|
adjustVolume,
|
|
799
894
|
airPlayCode,
|
|
800
895
|
canEnterAirPlayCode,
|
|
896
|
+
copyStationUrl,
|
|
897
|
+
openStationHomepage,
|
|
801
898
|
beginLearningTransportKey,
|
|
802
899
|
capturingTransportAction,
|
|
803
900
|
commandMode,
|
|
@@ -823,6 +920,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
823
920
|
playStation,
|
|
824
921
|
player,
|
|
825
922
|
playingStation,
|
|
923
|
+
recallSearchHistory,
|
|
826
924
|
moveExploreCursor: moveExploreMapCursor,
|
|
827
925
|
moveExploreCursorToCell: moveExploreMapCursorToCell,
|
|
828
926
|
refreshProviderHealth,
|
|
@@ -855,6 +953,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
855
953
|
toggleFavorite,
|
|
856
954
|
toggleMute,
|
|
857
955
|
togglePause,
|
|
956
|
+
toggleSetting,
|
|
858
957
|
toggleNearbyLocation,
|
|
859
958
|
toggleRadioGarden,
|
|
860
959
|
toggleSkipBrokenStreams
|
|
@@ -864,10 +963,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
864
963
|
}
|
|
865
964
|
const hasTopTabs = !layout.compact;
|
|
866
965
|
const globalFooter = playback.backend === 'ffplay'
|
|
867
|
-
? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · q quit'
|
|
966
|
+
? '←/→ tabs · F7/F9 or ,/. station · ffplay fallback: limited controls · t/v display · ? help · q quit'
|
|
868
967
|
: playback.backend === 'airplay'
|
|
869
|
-
? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · q quit'
|
|
870
|
-
: '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit';
|
|
968
|
+
? '←/→ tabs · F7/F9 or ,/. station · AirPlay: +/- volume, m mute · t/v display · ? help · q quit'
|
|
969
|
+
: '←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · ? help · q quit';
|
|
871
970
|
const playbackFooter = playbackFooterText({
|
|
872
971
|
station: playingStation,
|
|
873
972
|
playback,
|
|
@@ -888,7 +987,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
|
|
|
888
987
|
playbackBackend: playback.backend,
|
|
889
988
|
screen
|
|
890
989
|
});
|
|
891
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor:
|
|
990
|
+
return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, marginBottom: 1, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, rightLabel: `${playbackBackendLabel(playback.backend)} · ${playback.state}` }) })) : null, _jsxs(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: [_jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, 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: playback, playingStation: playingStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(playingStation), stationTime: stationApproximateTime(playingStation), storePath: store.filePath, theme: theme }), message ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: themeAccent(theme), children: message }) })) : null] }), _jsxs(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.panel, children: [playbackFooter ? _jsx(Text, { color: themeAccent(theme), children: playbackFooter }) : null, _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : 'gray', children: truncate(pageFooter, frameWidth) }), _jsx(Text, { color: textDim, children: truncate(globalFooter, frameWidth) })] })] }) }));
|
|
892
991
|
}
|
|
893
992
|
function buildLibraryStations(library) {
|
|
894
993
|
const stations = [];
|
|
@@ -937,7 +1036,7 @@ function formatDistanceKm(distanceKm) {
|
|
|
937
1036
|
}
|
|
938
1037
|
function nextAvailablePlaybackBackend(current, backends) {
|
|
939
1038
|
const options = ['auto'];
|
|
940
|
-
for (const backend of ['mpv', 'ffplay', 'airplay']) {
|
|
1039
|
+
for (const backend of ['mpv', 'ffplay', 'vlc', 'airplay']) {
|
|
941
1040
|
if (backends.includes(backend)) {
|
|
942
1041
|
options.push(backend);
|
|
943
1042
|
}
|
|
@@ -962,6 +1061,9 @@ function preferredLocalPlaybackBackend(backends) {
|
|
|
962
1061
|
if (backends.includes('ffplay')) {
|
|
963
1062
|
return 'ffplay';
|
|
964
1063
|
}
|
|
1064
|
+
if (backends.includes('vlc')) {
|
|
1065
|
+
return 'vlc';
|
|
1066
|
+
}
|
|
965
1067
|
return null;
|
|
966
1068
|
}
|
|
967
1069
|
function audioOutputSwitchLabel(output, backends) {
|
package/dist/ui/AppContent.js
CHANGED
|
@@ -10,6 +10,7 @@ import { StationScreen } from './screens/StationScreen.js';
|
|
|
10
10
|
import { NowPlayingScreen } from './screens/NowPlayingScreen.js';
|
|
11
11
|
import { StatsScreen } from './screens/StatsScreen.js';
|
|
12
12
|
import { SettingsScreen } from './screens/SettingsScreen.js';
|
|
13
|
+
import { HelpScreen } from './screens/HelpScreen.js';
|
|
13
14
|
import { AirPlaySettingsScreen } from './screens/AirPlaySettingsScreen.js';
|
|
14
15
|
import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
|
|
15
16
|
import { selectedAirPlayDevice } from './airplay-settings.js';
|
|
@@ -37,7 +38,7 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
|
|
|
37
38
|
return (_jsx(StationScreen, { title: stationContext.title, subtitle: stationContext.subtitle, stations: displayStations, selected: selected, loading: loadingStations, theme: theme, favorites: favoriteKeys, filterLabel: filterLabel, pageSize: layout.stationRows, width: frameWidth }));
|
|
38
39
|
}
|
|
39
40
|
if (screen === 'now-playing') {
|
|
40
|
-
return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, pulse: pulse, diagnostics: diagnostics, sleepLabel: sleepLabel, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, width: layout.receiverWidth, height: layout.receiverRows }));
|
|
41
|
+
return (_jsx(NowPlayingScreen, { station: playingStation, playback: playback, metadata: nowPlaying, theme: theme, favorite: stationFavorite, pulse: pulse, diagnostics: diagnostics, sleepLabel: sleepLabel, showDiagnostics: showDiagnostics, stationTime: stationTime, receiverStyle: library.settings.receiverStyle, trackHistory: library.trackHistory, width: layout.receiverWidth, height: layout.receiverRows }));
|
|
41
42
|
}
|
|
42
43
|
if (screen === 'stats') {
|
|
43
44
|
return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
|
|
@@ -51,5 +52,8 @@ export function AppContent({ airPlayDevices, airPlayCode, backends, countryFilte
|
|
|
51
52
|
if (screen === 'airplay-code') {
|
|
52
53
|
return (_jsx(AirPlayCodeScreen, { code: airPlayCode, playback: playback, selectedDevice: selectedAirPlayDevice(library.settings, airPlayDevices), theme: theme, width: frameWidth }));
|
|
53
54
|
}
|
|
55
|
+
if (screen === 'help') {
|
|
56
|
+
return _jsx(HelpScreen, { theme: theme, width: frameWidth });
|
|
57
|
+
}
|
|
54
58
|
return _jsx(Text, { children: "Unknown screen." });
|
|
55
59
|
}
|
package/dist/ui/app-state.js
CHANGED
|
@@ -6,7 +6,7 @@ const emptyMediaKeyBindings = {
|
|
|
6
6
|
};
|
|
7
7
|
const mediaTransportActions = ['previous', 'playPause', 'next'];
|
|
8
8
|
const sleepTimerOptions = [null, 15, 30, 60];
|
|
9
|
-
const playbackBackendOptions = ['auto', 'mpv', 'ffplay', 'airplay'];
|
|
9
|
+
const playbackBackendOptions = ['auto', 'mpv', 'ffplay', 'vlc', 'airplay'];
|
|
10
10
|
export const defaultExploreCursor = {
|
|
11
11
|
latitude: 48.8566,
|
|
12
12
|
longitude: 2.3522
|
|
@@ -52,6 +52,15 @@ export const topTabs = [
|
|
|
52
52
|
export function clamp(value, max) {
|
|
53
53
|
return Math.min(Math.max(value, 0), Math.max(max, 0));
|
|
54
54
|
}
|
|
55
|
+
export function searchEditingArrowAction(key, hasResults) {
|
|
56
|
+
if (key.upArrow) {
|
|
57
|
+
return key.ctrl || !hasResults ? 'history-older' : 'select-previous';
|
|
58
|
+
}
|
|
59
|
+
if (key.downArrow) {
|
|
60
|
+
return key.ctrl || !hasResults ? 'history-newer' : 'select-next';
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
55
64
|
export function clampVolume(value) {
|
|
56
65
|
return Math.min(100, Math.max(0, Math.round(value)));
|
|
57
66
|
}
|
package/dist/ui/ascii.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Map a single Unicode code point to a width-1 ASCII approximation. Used by the
|
|
2
|
+
// ASCII-safe display mode so terminals/fonts without braille, block, or
|
|
3
|
+
// box-drawing glyphs still render a legible receiver and chrome.
|
|
4
|
+
const shadeRamp = ' .:-=+*#%@';
|
|
5
|
+
function asciiGlyph(code) {
|
|
6
|
+
// Braille patterns: approximate by how many dots are raised (0-8).
|
|
7
|
+
if (code >= 0x2800 && code <= 0x28ff) {
|
|
8
|
+
const dots = popcount(code - 0x2800);
|
|
9
|
+
const index = Math.round((dots / 8) * (shadeRamp.length - 1));
|
|
10
|
+
return shadeRamp[index] ?? '#';
|
|
11
|
+
}
|
|
12
|
+
// Light/medium/dark shade blocks.
|
|
13
|
+
if (code === 0x2591) {
|
|
14
|
+
return '.';
|
|
15
|
+
}
|
|
16
|
+
if (code === 0x2592) {
|
|
17
|
+
return ':';
|
|
18
|
+
}
|
|
19
|
+
if (code === 0x2593) {
|
|
20
|
+
return '#';
|
|
21
|
+
}
|
|
22
|
+
// Block elements (full, partial, half blocks).
|
|
23
|
+
if (code >= 0x2580 && code <= 0x259f) {
|
|
24
|
+
return '#';
|
|
25
|
+
}
|
|
26
|
+
// Box drawing.
|
|
27
|
+
if (code >= 0x2500 && code <= 0x257f) {
|
|
28
|
+
if (code === 0x2500 || code === 0x2501 || code === 0x2504 || code === 0x2505) {
|
|
29
|
+
return '-';
|
|
30
|
+
}
|
|
31
|
+
if (code === 0x2502 || code === 0x2503 || code === 0x2506 || code === 0x2507) {
|
|
32
|
+
return '|';
|
|
33
|
+
}
|
|
34
|
+
return '+';
|
|
35
|
+
}
|
|
36
|
+
// Common standalone marks.
|
|
37
|
+
// Density-style marks: keep a low-to-high ramp so map textures stay legible.
|
|
38
|
+
if (code === 0x00b7) {
|
|
39
|
+
return '.'; // middle dot ·
|
|
40
|
+
}
|
|
41
|
+
if (code === 0x2026) {
|
|
42
|
+
return '.'; // ellipsis …
|
|
43
|
+
}
|
|
44
|
+
if (code === 0x2022) {
|
|
45
|
+
return 'o'; // bullet •
|
|
46
|
+
}
|
|
47
|
+
if (code === 0x25cf) {
|
|
48
|
+
return '@'; // black circle ●
|
|
49
|
+
}
|
|
50
|
+
if (code === 0x25cb || code === 0x25aa || code === 0x25a0) {
|
|
51
|
+
return '*';
|
|
52
|
+
}
|
|
53
|
+
if (code === 0x2605) {
|
|
54
|
+
return '*'; // ★
|
|
55
|
+
}
|
|
56
|
+
if (code === 0x2606) {
|
|
57
|
+
return 'o'; // ☆
|
|
58
|
+
}
|
|
59
|
+
if (code === 0x2014 || code === 0x2013) {
|
|
60
|
+
return '-';
|
|
61
|
+
}
|
|
62
|
+
// Drop zero-width and combining marks entirely so they do not skew widths.
|
|
63
|
+
if (code === 0x200b || code === 0xfeff || (code >= 0x0300 && code <= 0x036f)) {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
return '*';
|
|
67
|
+
}
|
|
68
|
+
function popcount(value) {
|
|
69
|
+
let count = 0;
|
|
70
|
+
let remaining = value;
|
|
71
|
+
while (remaining > 0) {
|
|
72
|
+
count += remaining & 1;
|
|
73
|
+
remaining >>>= 1;
|
|
74
|
+
}
|
|
75
|
+
return count;
|
|
76
|
+
}
|
|
77
|
+
export function toAsciiSafe(value) {
|
|
78
|
+
let out = '';
|
|
79
|
+
for (const char of value) {
|
|
80
|
+
const code = char.codePointAt(0) ?? 0;
|
|
81
|
+
out += code < 0x80 ? char : asciiGlyph(code);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
package/dist/ui/audio-output.js
CHANGED
|
@@ -9,6 +9,9 @@ export function audioOutputLabel(output) {
|
|
|
9
9
|
if (output === 'ffplay') {
|
|
10
10
|
return 'This device (ffplay fallback)';
|
|
11
11
|
}
|
|
12
|
+
if (output === 'vlc') {
|
|
13
|
+
return 'This device (VLC fallback)';
|
|
14
|
+
}
|
|
12
15
|
if (output === 'airplay') {
|
|
13
16
|
return 'AirPlay';
|
|
14
17
|
}
|
|
@@ -21,7 +24,7 @@ export function resolvedAudioOutput(output, backends) {
|
|
|
21
24
|
if (output !== 'auto') {
|
|
22
25
|
return backends.includes(output) ? output : null;
|
|
23
26
|
}
|
|
24
|
-
for (const backend of ['mpv', 'ffplay']) {
|
|
27
|
+
for (const backend of ['mpv', 'ffplay', 'vlc']) {
|
|
25
28
|
if (backends.includes(backend)) {
|
|
26
29
|
return backend;
|
|
27
30
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { textDim, themeAccent } from '../theme.js';
|
|
4
|
+
import { useDisplay } from '../display-context.js';
|
|
5
|
+
import { toAsciiSafe } from '../ascii.js';
|
|
4
6
|
import { truncate } from '../format.js';
|
|
5
7
|
// One title treatment shared by every screen: an accented, bold title trailed by
|
|
6
8
|
// a dim rule that fills the frame, with optional right-aligned meta and a muted
|
|
7
9
|
// subtitle below. Renders one row (plus one for the subtitle when present).
|
|
8
10
|
export function ScreenHeader({ title, width, theme, subtitle, right }) {
|
|
9
11
|
const accent = themeAccent(theme);
|
|
12
|
+
const { ascii } = useDisplay();
|
|
13
|
+
const a = (value) => (ascii ? toAsciiSafe(value) : value);
|
|
10
14
|
const safeTitle = truncate(title, Math.max(4, width - 4));
|
|
11
15
|
const rightText = right ? ` ${right}` : '';
|
|
12
16
|
const ruleWidth = Math.max(1, width - safeTitle.length - rightText.length - 2);
|
|
13
|
-
const rule = '─'.repeat(ruleWidth);
|
|
14
|
-
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: safeTitle }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: "gray", children: rightText }) : null] }), subtitle ? _jsx(Text, { color: "gray", children: truncate(subtitle, width) }) : null] }));
|
|
17
|
+
const rule = (ascii ? '-' : '─').repeat(ruleWidth);
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: accent, bold: true, children: a(safeTitle) }), _jsxs(Text, { color: textDim, children: [" ", rule] }), rightText ? _jsx(Text, { color: "gray", children: a(rightText) }) : null] }), subtitle ? _jsx(Text, { color: "gray", children: a(truncate(subtitle, width)) }) : null] }));
|
|
15
19
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import {
|
|
3
|
+
import { panelBorder, themeAccent } from '../theme.js';
|
|
4
|
+
import { useDisplay } from '../display-context.js';
|
|
4
5
|
import { truncate } from '../format.js';
|
|
5
6
|
export function TopTabs({ tabs, active, theme, width, rightLabel }) {
|
|
6
7
|
const accent = themeAccent(theme);
|
|
8
|
+
const { panel: panelBackground, ascii } = useDisplay();
|
|
9
|
+
const box = ascii
|
|
10
|
+
? { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
|
|
11
|
+
: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
|
|
7
12
|
const brand = 'RADIOCLI';
|
|
8
13
|
const rightText = ` ${rightLabel} `;
|
|
9
14
|
const bodyWidth = Math.max(1, width - 4);
|
|
@@ -15,7 +20,7 @@ export function TopTabs({ tabs, active, theme, width, rightLabel }) {
|
|
|
15
20
|
const visibleTabs = fitTabs(tabs, active, tabsAvailableWidth);
|
|
16
21
|
const visibleTabsWidth = tabsWidth(visibleTabs);
|
|
17
22
|
const tabPaddingWidth = Math.max(0, bodyWidth - visibleTabsWidth - (canShowRight ? rightText.length : 0));
|
|
18
|
-
return (_jsxs(Box, { flexDirection: "column", backgroundColor: panelBackground, width: width, children: [_jsxs(Text, { backgroundColor: panelBackground, children: [
|
|
23
|
+
return (_jsxs(Box, { flexDirection: "column", backgroundColor: panelBackground, width: width, children: [_jsxs(Text, { backgroundColor: panelBackground, children: [_jsxs(Text, { color: panelBorder, children: [box.tl, " "] }), _jsx(Text, { color: accent, bold: true, children: brand }), _jsxs(Text, { color: panelBorder, children: [" ", box.h.repeat(titleRuleWidth), box.tr] })] }), _jsxs(Text, { backgroundColor: panelBackground, children: [_jsxs(Text, { color: panelBorder, children: [box.v, " "] }), visibleTabs.map((item, index) => (_jsxs(Text, { children: [item.type === 'overflow' ? (_jsx(Text, { color: "gray", children: "\u2026" })) : (_jsx(Text, { color: item.tab.screen === active ? accent : 'gray', bold: item.tab.screen === active, children: item.tab.label })), index < visibleTabs.length - 1 ? _jsxs(Text, { color: "gray", children: [" ", box.v, " "] }) : null] }, item.type === 'overflow' ? `${item.side}-overflow` : item.tab.screen))), _jsx(Text, { children: ' '.repeat(tabPaddingWidth) }), canShowRight ? _jsx(Text, { color: "gray", children: rightText }) : null, _jsxs(Text, { color: panelBorder, children: [" ", box.v] })] }), _jsxs(Text, { backgroundColor: panelBackground, color: panelBorder, children: [box.bl, box.h.repeat(Math.max(0, width - 2)), box.br] })] }));
|
|
19
24
|
}
|
|
20
25
|
function fitTabs(tabs, active, maxWidth) {
|
|
21
26
|
const all = tabs.map(tab => ({ type: 'tab', tab }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { appBackground, panelBackground } from './theme.js';
|
|
3
|
+
const opaqueBackgrounds = { app: appBackground, panel: panelBackground };
|
|
4
|
+
const transparentBackgrounds = { app: undefined, panel: undefined };
|
|
5
|
+
// Respect the NO_COLOR convention (https://no-color.org): any non-empty value
|
|
6
|
+
// opts the user out of our forced colors, including the dark panel fills.
|
|
7
|
+
export function noColorRequested(env = process.env) {
|
|
8
|
+
return typeof env.NO_COLOR === 'string' && env.NO_COLOR.length > 0;
|
|
9
|
+
}
|
|
10
|
+
// Resolve the effective display mode from the user's settings and the NO_COLOR
|
|
11
|
+
// environment so light terminals keep their own background.
|
|
12
|
+
export function resolveDisplayMode(settings, env = process.env) {
|
|
13
|
+
const backgrounds = settings.transparentBackground || noColorRequested(env) ? transparentBackgrounds : opaqueBackgrounds;
|
|
14
|
+
return { ...backgrounds, ascii: Boolean(settings.asciiMode), reduceMotion: Boolean(settings.reduceMotion) };
|
|
15
|
+
}
|
|
16
|
+
const defaultMode = { ...opaqueBackgrounds, ascii: false, reduceMotion: false };
|
|
17
|
+
export const DisplayContext = createContext(defaultMode);
|
|
18
|
+
export function useDisplay() {
|
|
19
|
+
return useContext(DisplayContext);
|
|
20
|
+
}
|
|
21
|
+
// Ink's rounded/single borders are Unicode box-drawing; "classic" is ASCII
|
|
22
|
+
// (+-|) and renders on terminals and fonts without box-drawing glyphs.
|
|
23
|
+
export function panelBorderStyle(ascii, base = 'round') {
|
|
24
|
+
return ascii ? 'classic' : base;
|
|
25
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export const keyHelpSections = [
|
|
2
|
+
{
|
|
3
|
+
title: 'Navigation',
|
|
4
|
+
entries: [
|
|
5
|
+
{ keys: '↑/↓ or n/p', description: 'Move selection' },
|
|
6
|
+
{ keys: '←/→ or Tab', description: 'Switch tabs' },
|
|
7
|
+
{ keys: '[ / ]', description: 'Page selection by 10' },
|
|
8
|
+
{ keys: '1-9, 0', description: 'Jump to home menu item' },
|
|
9
|
+
{ keys: 'Enter', description: 'Open / tune the selection' },
|
|
10
|
+
{ keys: 'b or Esc', description: 'Back to home' },
|
|
11
|
+
{ keys: ': ', description: 'Command palette' },
|
|
12
|
+
{ keys: '?', description: 'Toggle this help' },
|
|
13
|
+
{ keys: 'q or Ctrl+C', description: 'Quit' }
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
title: 'Playback',
|
|
18
|
+
entries: [
|
|
19
|
+
{ keys: 'space or F8', description: 'Pause / resume' },
|
|
20
|
+
{ keys: ', / . or F7/F9', description: 'Previous / next station' },
|
|
21
|
+
{ keys: '+ / -', description: 'Volume up / down' },
|
|
22
|
+
{ keys: 'm', description: 'Mute / unmute' },
|
|
23
|
+
{ keys: 'f', description: 'Favorite the station' },
|
|
24
|
+
{ keys: 'O', description: 'Open station homepage' },
|
|
25
|
+
{ keys: 'y', description: 'Copy stream URL' },
|
|
26
|
+
{ keys: 's', description: 'Sleep timer (Now Playing)' },
|
|
27
|
+
{ keys: 'd', description: 'Diagnostics + recent tracks (Now Playing)' }
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: 'Display & discovery',
|
|
32
|
+
entries: [
|
|
33
|
+
{ keys: 't', description: 'Cycle display color' },
|
|
34
|
+
{ keys: 'v', description: 'Cycle receiver style' },
|
|
35
|
+
{ keys: 'o', description: 'Cycle audio output' },
|
|
36
|
+
{ keys: '/', description: 'Edit search / country filter' },
|
|
37
|
+
{ keys: 'w', description: 'Toggle country list / world map' },
|
|
38
|
+
{ keys: 'WASD', description: 'Move the Explore map cursor' },
|
|
39
|
+
{ keys: 'r', description: 'Refresh provider health' }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
export const commandHelp = [
|
|
44
|
+
{ name: 'search', args: '<query>', description: 'Search stations (alias :s)' },
|
|
45
|
+
{ name: 'country', args: '<name|code>', description: 'Open a country (alias :c)' },
|
|
46
|
+
{ name: 'codec', args: '<codec|any>', description: 'Filter by codec' },
|
|
47
|
+
{ name: 'language', args: '<lang|any>', description: 'Filter by language (alias :lang)' },
|
|
48
|
+
{ name: 'bitrate', args: '<kbps>', description: 'Minimum bitrate filter' },
|
|
49
|
+
{ name: 'clear', description: 'Clear search filters' },
|
|
50
|
+
{ name: 'volume', args: '<0-100>', description: 'Set volume (alias :vol)' },
|
|
51
|
+
{ name: 'mute', description: 'Toggle mute' },
|
|
52
|
+
{ name: 'sleep', args: '<minutes>', description: 'Set or clear the sleep timer' },
|
|
53
|
+
{ name: 'timeout', args: '<seconds>', description: 'Tune timeout (3-45)' },
|
|
54
|
+
{ name: 'skip', args: '<on|off>', description: 'Skip broken streams' },
|
|
55
|
+
{ name: 'location', args: '<on|off>', description: 'Nearby location lookup' },
|
|
56
|
+
{ name: 'doctor', description: 'Show playback backend status' },
|
|
57
|
+
{ name: 'resume', args: '<on|off>', description: 'Resume last station on launch' },
|
|
58
|
+
{ name: 'ascii', args: '<on|off>', description: 'ASCII-safe display' },
|
|
59
|
+
{ name: 'motion', args: '<on|off>', description: 'Reduce motion' },
|
|
60
|
+
{ name: 'background', args: '<on|off>', description: 'Transparent background' },
|
|
61
|
+
{ name: 'airplay', description: 'Open AirPlay settings' },
|
|
62
|
+
{ name: 'learn', args: '<previous|play|next>', description: 'Learn a media key' },
|
|
63
|
+
{ name: 'keys', args: '[reset]', description: 'Show or reset learned media keys' },
|
|
64
|
+
{ name: 'favorite', description: 'Favorite the current station (alias :fav)' },
|
|
65
|
+
{ name: 'library', description: 'Open the library' },
|
|
66
|
+
{ name: 'map', description: 'Open the world map' },
|
|
67
|
+
{ name: 'stats', description: 'Open listening stats' },
|
|
68
|
+
{ name: 'settings', description: 'Open settings' },
|
|
69
|
+
{ name: 'stop', description: 'Stop playback' },
|
|
70
|
+
{ name: 'help', description: 'Open this help' }
|
|
71
|
+
];
|
|
72
|
+
// Primary command names plus the aliases the executor accepts, used for
|
|
73
|
+
// command-mode tab completion.
|
|
74
|
+
export const commandNames = [
|
|
75
|
+
's',
|
|
76
|
+
'c',
|
|
77
|
+
'lang',
|
|
78
|
+
'vol',
|
|
79
|
+
'fav',
|
|
80
|
+
'recent',
|
|
81
|
+
'favorites',
|
|
82
|
+
'imports',
|
|
83
|
+
'bind',
|
|
84
|
+
'key',
|
|
85
|
+
'airplay-code',
|
|
86
|
+
'airplay-settings',
|
|
87
|
+
...commandHelp.map(entry => entry.name)
|
|
88
|
+
].sort((a, b) => a.localeCompare(b));
|
|
89
|
+
// Complete a partial command name to the longest shared prefix of its matches.
|
|
90
|
+
// Returns the original value when there is no match.
|
|
91
|
+
export function completeCommand(partial, names = commandNames) {
|
|
92
|
+
const lower = partial.toLowerCase();
|
|
93
|
+
const matches = names.filter(name => name.startsWith(lower));
|
|
94
|
+
if (matches.length === 0) {
|
|
95
|
+
return partial;
|
|
96
|
+
}
|
|
97
|
+
if (matches.length === 1) {
|
|
98
|
+
return matches[0];
|
|
99
|
+
}
|
|
100
|
+
return longestCommonPrefix(matches) || partial;
|
|
101
|
+
}
|
|
102
|
+
function longestCommonPrefix(values) {
|
|
103
|
+
if (values.length === 0) {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
let prefix = values[0];
|
|
107
|
+
for (const value of values.slice(1)) {
|
|
108
|
+
while (!value.startsWith(prefix)) {
|
|
109
|
+
prefix = prefix.slice(0, -1);
|
|
110
|
+
if (!prefix) {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return prefix;
|
|
116
|
+
}
|