ambient-display 1.1.0

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.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.prettierignore +4 -0
  3. package/.prettierrc +17 -0
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +84 -0
  6. package/ambient.config.js.template +13 -0
  7. package/env.template +2 -0
  8. package/eslint.config.js +24 -0
  9. package/jsconfig.json +19 -0
  10. package/package.json +67 -0
  11. package/screenshot.png +0 -0
  12. package/server/api/index.js +213 -0
  13. package/server/api/interact.js +234 -0
  14. package/server/api/utils.js +284 -0
  15. package/server/comms.js +13 -0
  16. package/server/config.js +27 -0
  17. package/server/constants.js +52 -0
  18. package/server/events.js +41 -0
  19. package/server/history.js +47 -0
  20. package/server/index.js +155 -0
  21. package/server/logs.js +15 -0
  22. package/server/memo.js +63 -0
  23. package/server/run.js +5 -0
  24. package/server/spotify/auth.js +98 -0
  25. package/server/spotify/index.js +105 -0
  26. package/server/spotify/sdk.js +217 -0
  27. package/server/types/comms.js +7 -0
  28. package/server/types/data.js +94 -0
  29. package/server/types/index.js +16 -0
  30. package/server/types/options.js +72 -0
  31. package/server/utils.js +101 -0
  32. package/src/app.d.ts +13 -0
  33. package/src/app.html +12 -0
  34. package/src/lib/actions/qr.svelte.js +23 -0
  35. package/src/lib/comms.js +25 -0
  36. package/src/lib/components/AuthenticateTrigger.svelte +74 -0
  37. package/src/lib/components/Controls.svelte +91 -0
  38. package/src/lib/components/ImageLoad.svelte +79 -0
  39. package/src/lib/components/LoadingIndicator.svelte +75 -0
  40. package/src/lib/components/MediaItem.svelte +75 -0
  41. package/src/lib/components/PlayingTracker.svelte +94 -0
  42. package/src/lib/components/ResultsList.svelte +80 -0
  43. package/src/lib/components/Toast/Item.svelte +71 -0
  44. package/src/lib/components/Toast/Manager.svelte +34 -0
  45. package/src/lib/icons/disc.svg +1 -0
  46. package/src/lib/index.js +1 -0
  47. package/src/lib/store.js +146 -0
  48. package/src/lib/styles.scss +166 -0
  49. package/src/lib/toast.js +57 -0
  50. package/src/lib/utils.js +723 -0
  51. package/src/routes/+layout.server.js +25 -0
  52. package/src/routes/+layout.svelte +72 -0
  53. package/src/routes/+page.svelte +381 -0
  54. package/src/routes/player/+page.svelte +294 -0
  55. package/static/favicon.ico +0 -0
  56. package/static/favicon.png +0 -0
  57. package/static/icons/144.favicon.png +0 -0
  58. package/static/icons/168.favicon.png +0 -0
  59. package/static/icons/192.favicon.png +0 -0
  60. package/static/icons/48.favicon.png +0 -0
  61. package/static/icons/72.favicon.png +0 -0
  62. package/static/icons/96.favicon.png +0 -0
  63. package/static/manifest.json +40 -0
  64. package/svelte.config.js +19 -0
  65. package/tools/BuildManifest.js +87 -0
  66. package/vite.config.js +46 -0
@@ -0,0 +1,234 @@
1
+ import { memo } from '../memo.js';
2
+ import { DEFAULT_OPTIONS } from '../constants.js';
3
+ import { trim, getContext } from './utils.js';
4
+
5
+ export const SpotifyInteract = {
6
+ device: {
7
+ /**
8
+ *
9
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
10
+ * @param {import('@spotify/web-api-ts-sdk').Market} market
11
+ */
12
+ async get(sdk, market = 'GB') {
13
+ return sdk.player.getPlaybackState(market);
14
+ }
15
+ },
16
+
17
+ artist: {
18
+ /**
19
+ *
20
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
21
+ * @param {string} id
22
+ * @param {import('@spotify/web-api-ts-sdk').Market} market
23
+ */
24
+ async topTracks(sdk, id, market = 'GB') {
25
+ /** @type {import('@spotify/web-api-ts-sdk').TopTracksResult} */
26
+ const results = await memo.use(memo.key('artist', 'tracks', id), () =>
27
+ sdk.artists.topTracks(id, market)
28
+ );
29
+
30
+ return {
31
+ tracks: results.tracks.map(trim.track)
32
+ };
33
+ }
34
+ },
35
+
36
+ album: {
37
+ /**
38
+ *
39
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
40
+ * @param {string} id
41
+ */
42
+ async get(sdk, id) {
43
+ /** @type {import('@spotify/web-api-ts-sdk').Album} */
44
+ const album = await memo.use(memo.key('album', id), () => sdk.albums.get(id));
45
+
46
+ return {
47
+ tracks: album.tracks.items
48
+ .map((item) => ({
49
+ ...item,
50
+ album: album
51
+ }))
52
+ .map(trim.track)
53
+ };
54
+ }
55
+ },
56
+
57
+ track: {
58
+ /**
59
+ *
60
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
61
+ * @param {string} id
62
+ */
63
+ async get(sdk, id) {
64
+ /** @type {import('@spotify/web-api-ts-sdk').Track} */
65
+ const track = await memo.use(memo.key('track', id), () => sdk.tracks.get(id));
66
+
67
+ return {
68
+ tracks: [trim.track(track)]
69
+ };
70
+ }
71
+ },
72
+
73
+ queue: {
74
+ /**
75
+ *
76
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
77
+ */
78
+ async get(sdk) {
79
+ const queue = await sdk.player.getUsersQueue();
80
+
81
+ if (!queue) {
82
+ return {
83
+ noQueue: true
84
+ };
85
+ }
86
+
87
+ return {
88
+ items: [queue.currently_playing, ...queue.queue]
89
+ .filter((item) => !!item)
90
+ .filter((item) => !['episode', 'track'].includes(item.type))
91
+ .map((item) => {
92
+ switch (item.type) {
93
+ case 'episode':
94
+ return trim.episode(item);
95
+ default:
96
+ return trim.track(item);
97
+ }
98
+ })
99
+ };
100
+ },
101
+
102
+ /**
103
+ *
104
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
105
+ * @param {string} uri
106
+ */
107
+ async add(sdk, uri) {
108
+ const { queue } = await sdk.player.getUsersQueue();
109
+
110
+ // Check if the item the user is trying to add is already in the queue
111
+ if (queue.some((item) => item.uri === uri)) {
112
+ return {
113
+ success: false
114
+ };
115
+ }
116
+
117
+ await sdk.player.addItemToPlaybackQueue(uri);
118
+
119
+ return {
120
+ success: true
121
+ };
122
+ }
123
+ },
124
+
125
+ search: {
126
+ /**
127
+ *
128
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
129
+ * @param {string} q
130
+ * @param {import('@spotify/web-api-ts-sdk').Market} q
131
+ * @param {number} q
132
+ */
133
+ async query(
134
+ sdk,
135
+ q,
136
+ market = DEFAULT_OPTIONS.api.market,
137
+ searchQueryLimit = DEFAULT_OPTIONS.api.searchQueryLimit
138
+ ) {
139
+ const results = await memo.use(memo.key('search', q), () =>
140
+ sdk.search(q, ['track', 'artist', 'album'], market, searchQueryLimit)
141
+ );
142
+
143
+ return {
144
+ albums: results.albums.items.map(trim.album),
145
+ artists: results.artists.items.map(trim.artist),
146
+ tracks: results.tracks.items.map(trim.track)
147
+ };
148
+ }
149
+ },
150
+
151
+ context: {
152
+ /**
153
+ *
154
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
155
+ */
156
+ async get(sdk, uri) {
157
+ /** @type {Types.ApiContext | {}} */
158
+ const context = await memo.use(uri, () => getContext(sdk, uri));
159
+
160
+ return context;
161
+ }
162
+ },
163
+
164
+ info: {
165
+ /**
166
+ *
167
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
168
+ * @param {import('@spotify/web-api-ts-sdk').Market} [market]
169
+ */
170
+ async get(sdk, market = DEFAULT_OPTIONS.api.market) {
171
+ const track = await sdk.player.getCurrentlyPlayingTrack(market, 'episode');
172
+
173
+ if (!track) {
174
+ return {
175
+ noTrack: true
176
+ };
177
+ }
178
+
179
+ const context = await SpotifyInteract.context.get(sdk, track.context.uri);
180
+
181
+ return {
182
+ isPlaying: track.is_playing,
183
+ track:
184
+ track.currently_playing_type === 'episode'
185
+ ? trim.episode(track.item)
186
+ : trim.track(track.item),
187
+ context,
188
+ player: {
189
+ current: track.progress_ms,
190
+ duration: track.item.duration_ms
191
+ }
192
+ };
193
+ }
194
+ },
195
+
196
+ player: {
197
+ /**
198
+ *
199
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
200
+ */
201
+ async play(sdk) {
202
+ await sdk.player.startResumePlayback();
203
+
204
+ return { success: true };
205
+ },
206
+ /**
207
+ *
208
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
209
+ */
210
+ async pause(sdk) {
211
+ await sdk.player.pausePlayback();
212
+
213
+ return { success: true };
214
+ },
215
+ /**
216
+ *
217
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
218
+ */
219
+ async forward(sdk) {
220
+ await sdk.player.skipToNext();
221
+
222
+ return { success: true };
223
+ },
224
+ /**
225
+ *
226
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
227
+ */
228
+ async back(sdk) {
229
+ await sdk.player.skipToPrevious();
230
+
231
+ return { success: true };
232
+ }
233
+ }
234
+ };
@@ -0,0 +1,284 @@
1
+ import * as Types from '../types/data.js';
2
+
3
+ /**
4
+ *
5
+ * @param {string} id
6
+ * @param {string} name
7
+ * @param {string} subtitle
8
+ * @param {string} uri
9
+ * @param {{url: string, width: number, height: number}[]} images
10
+ * @returns {Types.ApiNormalisedItem}
11
+ */
12
+ export const normalisedData = (id, name, subtitle, uri, images) => ({
13
+ id,
14
+ title: name,
15
+ subtitle,
16
+ uri: uri,
17
+ image: {
18
+ full: images.at(0),
19
+ low: images.at(-1)
20
+ }
21
+ });
22
+
23
+ export const trim = {
24
+ /**
25
+ *
26
+ * @param {import('@spotify/web-api-ts-sdk').Track} track
27
+ * @returns {Types.ApiTrackItem & Types.ApiNormalisedItem}
28
+ */
29
+ track(track) {
30
+ return {
31
+ id: track.id,
32
+ normalised: normalisedData(
33
+ track.id,
34
+ track.name,
35
+ track.artists[0].name,
36
+ track.uri,
37
+ track?.album?.images ?? []
38
+ ),
39
+ title: track.name,
40
+ album: track.album.name,
41
+ artist: track.artists[0].name,
42
+ artists: track.artists.map((artist) => artist.name),
43
+ number: track.track_number,
44
+ uri: track.uri,
45
+ image: {
46
+ full: track.album.images.at(0),
47
+ low: track.album.images.at(-1)
48
+ }
49
+ };
50
+ },
51
+ /**
52
+ *
53
+ * @param {import('@spotify/web-api-ts-sdk').Playlist} playlist
54
+ * @returns {Types.ApiPlaylistItem & Types.ApiNormalisedItem}
55
+ */
56
+ playlist(playlist) {
57
+ return {
58
+ id: playlist.id,
59
+ normalised: normalisedData(
60
+ playlist.id,
61
+ playlist.name,
62
+ playlist.owner.display_name,
63
+ playlist.uri,
64
+ playlist.images
65
+ ),
66
+ title: playlist.name,
67
+ owner: playlist.owner.display_name,
68
+ total: playlist.tracks.total,
69
+ uri: playlist.uri,
70
+ image: {
71
+ full: playlist.images.at(0),
72
+ low: playlist.images.at(-1)
73
+ }
74
+ };
75
+ },
76
+ /**
77
+ *
78
+ * @param {import('@spotify/web-api-ts-sdk').Artist} artist
79
+ * @returns {Types.ApiArtistItem & Types.ApiNormalisedItem}
80
+ */
81
+ artist(artist) {
82
+ return {
83
+ id: artist.id,
84
+ normalised: normalisedData(artist.id, artist.name, '', artist.uri, artist.images),
85
+ title: artist.name,
86
+ uri: artist.uri,
87
+ image: {
88
+ full: artist.images.at(0),
89
+ low: artist.images.at(-1)
90
+ }
91
+ };
92
+ },
93
+ /**
94
+ *
95
+ * @param {import('@spotify/web-api-ts-sdk').Album} album
96
+ * @returns {Types.ApiAlbumItem & Types.ApiNormalisedItem}
97
+ */
98
+ album(album) {
99
+ return {
100
+ normalised: normalisedData(
101
+ album.id,
102
+ album.name,
103
+ album.release_date.split('-').shift() ?? '',
104
+ album.uri,
105
+ album.images
106
+ ),
107
+ id: album.id,
108
+ title: album.name,
109
+ release: album.release_date,
110
+ uri: album.uri,
111
+ total: album.total_tracks,
112
+ image: {
113
+ full: album.images.at(0),
114
+ low: album.images.at(-1)
115
+ }
116
+ };
117
+ },
118
+ /**
119
+ *
120
+ * @param {import('@spotify/web-api-ts-sdk').Episode} episode
121
+ * @returns {Types.ApiEpisodeItem & Types.ApiNormalisedItem}
122
+ */
123
+ episode(episode) {
124
+ return {
125
+ id: episode.id,
126
+ normalised: normalisedData(
127
+ episode.id,
128
+ episode.name,
129
+ episode.show.name,
130
+ episode.uri,
131
+ episode.images
132
+ ),
133
+ title: episode.name,
134
+ show: episode.show.name,
135
+ release: episode.release_date,
136
+ uri: episode.uri,
137
+ image: {
138
+ full: episode.images.at(0),
139
+ low: episode.images.at(-1)
140
+ }
141
+ };
142
+ },
143
+ /**
144
+ *
145
+ * @param {import('@spotify/web-api-ts-sdk').Show} show
146
+ * @returns {Types.ApiShowItem & Types.ApiNormalisedItem}
147
+ */
148
+ show(show) {
149
+ return {
150
+ id: show.id,
151
+ normalised: normalisedData(show.id, show.name, '', show.uri, show.images),
152
+ title: show.name,
153
+ uri: show.uri,
154
+ image: {
155
+ full: show.images.at(0),
156
+ low: show.images.at(-1)
157
+ }
158
+ };
159
+ }
160
+ };
161
+
162
+ /**
163
+ *
164
+ * @param {string} uri Spotify URI
165
+ */
166
+ export const deconstructUri = (uri) => {
167
+ const [_, type, id] = uri.split(':');
168
+
169
+ return {
170
+ type,
171
+ id
172
+ };
173
+ };
174
+
175
+ /**
176
+ *
177
+ * @param {import('@spotify/web-api-ts-sdk').SpotifyApi} sdk
178
+ * @param {string} uri
179
+ * @return {Promise<Types.ApiContext | {}>}
180
+ */
181
+ export const getContext = async (sdk, uri) => {
182
+ const { type, id } = deconstructUri(uri);
183
+
184
+ try {
185
+ switch (type) {
186
+ case 'playlist': {
187
+ const playlist = await sdk.playlists.getPlaylist(id);
188
+
189
+ return {
190
+ ...trim.playlist(playlist),
191
+ type: 'playlist'
192
+ };
193
+ }
194
+ case 'artist': {
195
+ const artist = await sdk.artists.get(id);
196
+
197
+ return {
198
+ ...trim.artist(artist),
199
+ type: 'artist'
200
+ };
201
+ }
202
+ case 'album': {
203
+ const album = await sdk.albums.get(id);
204
+
205
+ return {
206
+ ...trim.album(album),
207
+ type: 'album'
208
+ };
209
+ }
210
+ case 'show': {
211
+ const show = await sdk.shows.get(id);
212
+
213
+ return {
214
+ ...trim.show(show),
215
+ type: 'show'
216
+ };
217
+ }
218
+ default: {
219
+ return {};
220
+ }
221
+ }
222
+ } catch {
223
+ return {};
224
+ }
225
+ };
226
+
227
+ /**
228
+ *
229
+ * @param {(req: import('express').Request & {sdk: import('@spotify/web-api-ts-sdk').SpotifyApi}, res: import('express').Response, next?: import('express').NextFunction)} handler
230
+ * @returns
231
+ */
232
+ export function apiWrapper(handler) {
233
+ return async (
234
+ /** @type {import('express').Request & {sdk: import('@spotify/web-api-ts-sdk').SpotifyApi, history: import('../history.js').CommandHistory}} */ req,
235
+ /** @type {import('express').Response} */ res,
236
+ /** @type {import('express').NextFunction} */ next
237
+ ) => {
238
+ try {
239
+ req.history.add({
240
+ type: req.path,
241
+ params: req.params
242
+ });
243
+ await handler(req, res);
244
+ } catch (e) {
245
+ console.error('API Error', e);
246
+ // req.comms.error('Check Logs');
247
+ next(e);
248
+ }
249
+ };
250
+ }
251
+
252
+ const SpotifyRegExp = new RegExp(
253
+ /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:(track|album|artist)\/|\?uri=spotify:(track|album|artist):)((\w|-){22})/
254
+ );
255
+
256
+ export function isSpotifyUrl(url) {
257
+ return SpotifyRegExp.test(url);
258
+ }
259
+
260
+ export function SpotifyUrl(url) {
261
+ const [_, typeOne, typeTwo, id] = SpotifyRegExp.exec(url);
262
+
263
+ return {
264
+ type: typeOne || typeTwo,
265
+ id
266
+ };
267
+ }
268
+
269
+ /**
270
+ *
271
+ * @param {boolean[]} switches
272
+ * @returns {(req: import("express").Request, req: import("express").Response, req: import("express").NextFunction)}
273
+ */
274
+ export const protect = (switches) => {
275
+ return (req, res, next) => {
276
+ if (!switches.every((item) => !!item)) {
277
+ req.comms.error('Not allowed');
278
+
279
+ return res.json({ success: false });
280
+ }
281
+
282
+ next();
283
+ };
284
+ };
@@ -0,0 +1,13 @@
1
+ export const comms = (io) => {
2
+ return {
3
+ message(message, type = 'info') {
4
+ return io.emit('message', {
5
+ type,
6
+ message
7
+ });
8
+ },
9
+ error(message) {
10
+ return this.message(`Error: ${message}`, 'error');
11
+ }
12
+ };
13
+ };
@@ -0,0 +1,27 @@
1
+ import deepmerge from 'deepmerge';
2
+ import dotenv from 'dotenv';
3
+ import * as Types from './types/options.js';
4
+
5
+ import { DEFAULT_OPTIONS } from './constants.js';
6
+ import { expandAliases, getIp } from './utils.js';
7
+
8
+ dotenv.config();
9
+
10
+ const INJECTED_OPTIONS = {
11
+ verbose: 'VERBOSE' in process.env && process.env.VERBOSE?.toLowerCase() !== 'false',
12
+ origin: process.env.ORIGIN ?? getIp(),
13
+ protocol: process.env.PROTOCOL ?? 'http://',
14
+ port: process.env.PORT ?? 3000,
15
+ spotify: {
16
+ client_id: process.env.SPOTIFY_CLIENT_ID,
17
+ client_secret: process.env.SPOTIFY_CLIENT_SECRET
18
+ }
19
+ };
20
+
21
+ /** @type {Types.Config} */
22
+ const USER_OPTIONS = await import(expandAliases(process.env.CONFIG ?? '../ambient.config.js'))
23
+ .then((module) => module.default)
24
+ .catch(() => ({}));
25
+
26
+ /** @type {Types.SpotifyAmbientDisplayOptions & {plugins: Types.Config['plugins']}} */
27
+ export const OPTIONS = deepmerge(DEFAULT_OPTIONS, deepmerge(INJECTED_OPTIONS, USER_OPTIONS));
@@ -0,0 +1,52 @@
1
+ import * as Types from './types/options.js';
2
+
3
+ export const ERROR = {
4
+ GENERAL: 'api/general',
5
+ UNAUTHENTICATED: 'api/unauthenticated',
6
+ SPOTIFY_UNAUTHENTICATED: 'spotify/unauthenticated',
7
+ SPOTIFY_REAUTHENTICATE: 'spotify/reauthenticate',
8
+
9
+ SPOTIFY_RATE_LIMIT: 'spotify/rate-limit',
10
+ SPOTIFY_RESTRICTED: 'spotify/restricted',
11
+ SPOTIFY_ERROR: 'spotify/spotify-general'
12
+ };
13
+
14
+ export const EVENT = {
15
+ APP_ERROR: 'app:error',
16
+ SYSTEM: 'system'
17
+ };
18
+
19
+ /** @type {Types.Config} */
20
+ export const DEFAULT_OPTIONS = {
21
+ port: 3000,
22
+ origin: '',
23
+ protocol: 'http://',
24
+
25
+ verbose: false,
26
+
27
+ playerRoute: '/player',
28
+
29
+ api: {
30
+ market: 'GB',
31
+ searchQueryLimit: 10,
32
+ centralisedPolling: true,
33
+ centralisedPollingTimer: 5000,
34
+ canAdd: true,
35
+ canControl: true
36
+ },
37
+
38
+ spotify: {
39
+ client_id: '',
40
+ client_secret: '',
41
+ routePrefix: '/spotify',
42
+ routeToken: '/token',
43
+ authenticatedRedirect: '/',
44
+ accessTokenJsonLocation: '$HOME/.ambient/spotify_auth.json',
45
+ scope: []
46
+ },
47
+
48
+ plugins: [],
49
+ pluginOptions: {},
50
+
51
+ suppressErrors: []
52
+ };
@@ -0,0 +1,41 @@
1
+ import EventEmitter from 'node:events';
2
+ import { EVENT } from './constants.js';
3
+
4
+ export class AppEventEmitter extends EventEmitter {
5
+ constructor() {
6
+ super();
7
+ }
8
+
9
+ /**
10
+ *
11
+ * @param {string} eventName
12
+ * @param {string} message
13
+ * @param {Record<any, any>} [data]
14
+ */
15
+ emit(eventName, message, data = {}) {
16
+ super.emit(eventName, {
17
+ message,
18
+ data
19
+ });
20
+ }
21
+
22
+ /**
23
+ *
24
+ * @param {string} message
25
+ * @param {any} detail
26
+ */
27
+ error(message, detail = null) {
28
+ this.emit(EVENT.APP_ERROR, message, detail);
29
+ }
30
+
31
+ /**
32
+ *
33
+ * @param {string} event
34
+ * @param {string} message
35
+ */
36
+ system(event, message = '') {
37
+ this.emit([EVENT.SYSTEM, event].join(':'), message);
38
+ }
39
+ }
40
+
41
+ export const events = new AppEventEmitter();
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @typedef {object} CommandHistory
3
+ * @property {CommandHistoryItem[]} items
4
+ * @property {CommandHistoryItem | undefined} last
5
+ * @property {(item: CommandHistoryItem) => CommandHistory} add
6
+ */
7
+
8
+ /**
9
+ * @typedef {{type: string} & Record<string, any>} CommandHistoryItem
10
+ */
11
+
12
+ /**
13
+ *
14
+ * @param {number} [size]
15
+ * @returns {CommandHistory}
16
+ */
17
+ export const CommandHistory = (size = 10) => {
18
+ let maxSize = size;
19
+
20
+ /** @type {CommandHistoryItem[]} */
21
+ let items = [];
22
+
23
+ return {
24
+ get items() {
25
+ return items;
26
+ },
27
+ get last() {
28
+ return items.at(-1);
29
+ },
30
+
31
+ /**
32
+ *
33
+ * @param {CommandHistoryItem} item
34
+ * @returns {CommandHistory}
35
+ */
36
+ add(item) {
37
+ const i = items.slice().reverse();
38
+
39
+ i.unshift(item);
40
+ i.slice(0, maxSize);
41
+
42
+ items = i.reverse();
43
+
44
+ return this;
45
+ }
46
+ };
47
+ };