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,25 @@
1
+ import { OPTIONS } from '$server/config.js';
2
+
3
+ const testServer = async (fetch) => {
4
+ try {
5
+ const { success } = await fetch({ path: '/api/health', protocol: 'http' }).then((resp) =>
6
+ resp.json()
7
+ );
8
+
9
+ return success;
10
+ } catch {
11
+ return false;
12
+ }
13
+ };
14
+
15
+ /** @type {import('./$types').LayoutServerLoad} */
16
+ export async function load({ fetch }) {
17
+ const server = await testServer(fetch);
18
+
19
+ const { plugins, ...config } = OPTIONS;
20
+
21
+ return {
22
+ live: !!server,
23
+ config
24
+ };
25
+ }
@@ -0,0 +1,72 @@
1
+ <script>
2
+ import 'normalize.css';
3
+ import '$lib/styles.scss';
4
+ import { api, authenticated, config, liveData, settled } from '$lib/store';
5
+ import { socket } from '$lib/comms';
6
+
7
+ import AuthenticateTrigger from '$lib/components/AuthenticateTrigger.svelte';
8
+ import { address } from '$lib/store';
9
+ import ToastManager from '$lib/components/Toast/Manager.svelte';
10
+ import { toastItems } from '$lib/toast';
11
+ import LoadingIndicator from '$lib/components/LoadingIndicator.svelte';
12
+
13
+ /** @type {{ data: import('./$types').LayoutData, children: import('svelte').Snippet }} */
14
+ let { data, children } = $props();
15
+
16
+ let loading = $state(true);
17
+
18
+ async function determineAppState() {
19
+ const { authenticated: serverAuthenticated = false } = await $api.health();
20
+
21
+ authenticated.set(serverAuthenticated);
22
+ }
23
+
24
+ $effect(() => {
25
+ $socket?.on('message', (item) => {
26
+ toastItems.addItem(item);
27
+ });
28
+ $socket?.on('reload', (item) => {
29
+ window.location.reload();
30
+ });
31
+ });
32
+
33
+ $effect(() => {
34
+ config.set(data.config);
35
+ liveData.set(data.live);
36
+ settled.set(true);
37
+
38
+ determineAppState();
39
+
40
+ loading = false;
41
+ });
42
+ </script>
43
+
44
+ <svelte:head>
45
+ <title>Spotify Party</title>
46
+ <meta name="description" content="A frontend for social spotify-ing" />
47
+
48
+ <meta property="og:title" content="Spotify Party" />
49
+ <meta property="og:description" content="A frontend for social spotify-ing" />
50
+ <meta property="og:type" content="website" />
51
+ <meta property="og:url" content={$address.get('/')} />
52
+ <meta property="og:image" content={$address.get('/social.jpg')} />
53
+ <meta name="twitter:card" content="summary_large_image" />
54
+ <meta name="twitter:title" content="Spotify Party" />
55
+ <meta property="twitter:url" content={$address.get('/')} />
56
+ <meta name="twitter:description" content="A frontend for social spotify-ing" />
57
+ <meta name="twitter:image" content={$address.get('/social.jpg')} />
58
+
59
+ <meta name="theme-color" content="#0000000" />
60
+
61
+ <link rel="icon" href="/favicon.ico" />
62
+ <link rel="manifest" href="/manifest.json" />
63
+ </svelte:head>
64
+
65
+ {#if loading}
66
+ <LoadingIndicator floating />
67
+ {:else if !$authenticated}
68
+ <AuthenticateTrigger />
69
+ {:else}
70
+ <ToastManager />
71
+ {@render children()}
72
+ {/if}
@@ -0,0 +1,381 @@
1
+ <script>
2
+ import Controls from '$lib/components/Controls.svelte';
3
+ import LoadingIndicator from '$lib/components/LoadingIndicator.svelte';
4
+ import MediaItem from '$lib/components/MediaItem.svelte';
5
+ import PlayingTracker from '$lib/components/PlayingTracker.svelte';
6
+ import ResultsList from '$lib/components/ResultsList.svelte';
7
+ import { api, config } from '$lib/store';
8
+ import { toastItems } from '$lib/toast';
9
+ import * as DataTypes from '$server/types/data.js';
10
+ import { CaretBack, Sparkles } from 'svelte-ionicons';
11
+
12
+ /** @type {DataTypes.ApiSearchResponse | null}*/
13
+ let items = $state(null);
14
+ let loading = $state(false);
15
+
16
+ /** @type {'tracks' | 'artists' | 'albums'}*/
17
+ let expanded = $state('tracks');
18
+
19
+ /** @type {DataTypes.ApiNormalisedItem | null} */
20
+ let contextHeader = $state(null);
21
+
22
+ /** @type {DataTypes.ApiInfoResponse | null} */
23
+ let playing = $state(null);
24
+
25
+ /**
26
+ *
27
+ * @param {() => Promise<DataTypes.ApiSearchResponse>} requestPromise
28
+ */
29
+ async function onRequest(requestPromise) {
30
+ try {
31
+ loading = true;
32
+ items = null;
33
+
34
+ const { tracks, artists, albums } = await requestPromise();
35
+
36
+ items = {
37
+ tracks,
38
+ artists,
39
+ albums
40
+ };
41
+ } catch (e) {
42
+ console.error(e);
43
+ toastItems.addItem({
44
+ message: 'There was an error - Try refreshing',
45
+ type: 'error'
46
+ });
47
+ } finally {
48
+ loading = false;
49
+ }
50
+ }
51
+
52
+ /** @type {HTMLFormElement | null} */
53
+ let searchForm = $state(null);
54
+ async function onSearch(e) {
55
+ e.preventDefault();
56
+
57
+ if (!searchForm) {
58
+ return;
59
+ }
60
+
61
+ const fd = new FormData(searchForm);
62
+
63
+ const q = fd.get('q')?.toString();
64
+
65
+ if (q) {
66
+ contextHeader = null;
67
+ onRequest(() => $api.search(q));
68
+ }
69
+ }
70
+
71
+ /**
72
+ *
73
+ * @param {string} uri
74
+ * @param {string} name
75
+ */
76
+ async function addTrack(uri, name) {
77
+ try {
78
+ loading = true;
79
+ await $api.addTrack(uri, name);
80
+ } catch (e) {
81
+ console.error(e);
82
+ } finally {
83
+ loading = false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ *
89
+ * @param {DataTypes.ApiNormalisedItem} item
90
+ */
91
+ async function searchAlbum(item) {
92
+ contextHeader = item;
93
+ onRequest(() => $api.album(item.id));
94
+ expanded = 'tracks';
95
+ }
96
+
97
+ /**
98
+ *
99
+ * @param {DataTypes.ApiNormalisedItem} item
100
+ */
101
+ async function searchArtist(item) {
102
+ contextHeader = item;
103
+ onRequest(() => $api.artist(item.id));
104
+ expanded = 'tracks';
105
+ }
106
+ </script>
107
+
108
+ <PlayingTracker bind:playing />
109
+
110
+ <div class="page bg-color-bg" class:loading>
111
+ {#if playing && !('noTrack' in playing)}
112
+ <Controls
113
+ playing={playing.isPlaying}
114
+ title={[playing.track.normalised.title, playing.track.normalised.subtitle].join(' - ')}
115
+ canControl={$config.api?.canControl}
116
+ />
117
+ {/if}
118
+
119
+ {#if $config?.api?.canAdd}
120
+ <div class="page-content bg-color-3">
121
+ <form bind:this={searchForm} class="page-content-top" onsubmit={onSearch}>
122
+ <label>
123
+ <input
124
+ class="color-bg"
125
+ type="search"
126
+ placeholder="URL, Track, Album, Artist..."
127
+ name="q"
128
+ disabled={loading}
129
+ />
130
+ </label>
131
+ <button class="btn-reset" disabled={loading}>Search</button>
132
+ </form>
133
+
134
+ <div class="page-content-results">
135
+ {#if loading}
136
+ <LoadingIndicator floating />
137
+ {/if}
138
+
139
+ {#if contextHeader}
140
+ <div class="context-header">
141
+ <button onclick={onSearch} class="context-header-action btn-reset">
142
+ <CaretBack size="28" />
143
+ </button>
144
+
145
+ <div class="context-header-item">
146
+ <MediaItem {...contextHeader} />
147
+ </div>
148
+ </div>
149
+ {/if}
150
+
151
+ {#if items?.tracks}
152
+ <ResultsList
153
+ title="Tracks"
154
+ onActivate={() => (expanded = 'tracks')}
155
+ expanded={expanded === 'tracks'}
156
+ items={items.tracks}
157
+ >
158
+ {#snippet action(item)}
159
+ <button
160
+ disabled={loading}
161
+ onclick={() => addTrack(item.uri, item.title)}
162
+ class="btn-reset select-track size-small-1">Add Track</button
163
+ >
164
+ {/snippet}
165
+ </ResultsList>
166
+ {/if}
167
+
168
+ {#if items?.albums}
169
+ <ResultsList
170
+ title="Albums"
171
+ onActivate={() => (expanded = 'albums')}
172
+ expanded={expanded === 'albums'}
173
+ items={items.albums}
174
+ >
175
+ {#snippet action(item)}
176
+ <button
177
+ onclick={() => searchAlbum(item)}
178
+ disabled={loading}
179
+ class="btn-reset select-track size-small-1">View Album</button
180
+ >
181
+ {/snippet}
182
+ </ResultsList>
183
+ {/if}
184
+
185
+ {#if items?.artists}
186
+ <ResultsList
187
+ title="Artists"
188
+ onActivate={() => (expanded = 'artists')}
189
+ expanded={expanded === 'artists'}
190
+ items={items.artists}
191
+ >
192
+ {#snippet action(item)}
193
+ <button
194
+ onclick={() => searchArtist(item)}
195
+ disabled={loading}
196
+ class="btn-reset select-track size-small-1">View Artist</button
197
+ >
198
+ {/snippet}
199
+ </ResultsList>
200
+ {/if}
201
+
202
+ {#if !items}
203
+ <div class="initial color-1">Search for a track to add</div>
204
+ {/if}
205
+ </div>
206
+ </div>
207
+ {:else}
208
+ <div class="warning">
209
+ <Sparkles />
210
+ <p>Host has disabled adding for this party</p>
211
+ </div>
212
+ {/if}
213
+
214
+ <div class="outlink size-small-2 color-2">
215
+ <a href="/player">View player</a>
216
+ </div>
217
+ </div>
218
+
219
+ <style lang="scss">
220
+ .page {
221
+ padding: var(--spacing-large);
222
+
223
+ width: 100vw;
224
+ height: 100dvh;
225
+
226
+ display: flex;
227
+
228
+ flex-direction: column;
229
+
230
+ justify-content: stretch;
231
+ align-items: center;
232
+ gap: var(--spacing-large);
233
+
234
+ transition: {
235
+ duration: 0.5s;
236
+ property: background-color;
237
+ }
238
+
239
+ &-content {
240
+ flex: 1;
241
+
242
+ border-radius: var(--border-radius-large);
243
+ overflow: hidden;
244
+ isolation: isolate;
245
+
246
+ max-width: 400px;
247
+
248
+ display: grid;
249
+
250
+ grid-template-rows: auto 1fr;
251
+ }
252
+ }
253
+
254
+ .page-content-top {
255
+ display: flex;
256
+
257
+ label {
258
+ flex: 1;
259
+
260
+ input {
261
+ width: 100%;
262
+
263
+ padding: var(--spacing-normal);
264
+ margin: 0;
265
+ border: none;
266
+ font-weight: inherit;
267
+ outline: 0;
268
+ background-color: var(--color-3);
269
+ color: var(--color-1);
270
+ border-radius: 0;
271
+ font-size: var(--font-size-level-headline-3);
272
+
273
+ @media screen and (min-width: 768px) {
274
+ font-size: var(--font-size-level-headline-2);
275
+ }
276
+
277
+ &::placeholder {
278
+ color: var(--color-1);
279
+ }
280
+
281
+ &:focus {
282
+ background-color: var(--color-2);
283
+ }
284
+ }
285
+ }
286
+
287
+ button {
288
+ font-weight: inherit;
289
+
290
+ padding: var(--spacing-normal) var(--spacing-large);
291
+
292
+ background-color: var(--color-highlight);
293
+ color: var(--color-1);
294
+ }
295
+ }
296
+
297
+ .page-content-results {
298
+ min-height: 0;
299
+
300
+ display: grid;
301
+
302
+ .loading & {
303
+ opacity: 0.5;
304
+ }
305
+ }
306
+
307
+ .select-track {
308
+ background-color: var(--color-1);
309
+ color: var(--color-bg);
310
+
311
+ border-radius: var(--border-radius-normal);
312
+ padding: var(--spacing-small) var(--spacing-normal);
313
+
314
+ &:hover,
315
+ &:focus-visible {
316
+ background-color: var(--color-2);
317
+ }
318
+ }
319
+
320
+ .initial {
321
+ display: flex;
322
+
323
+ align-items: center;
324
+ justify-content: center;
325
+ }
326
+
327
+ .context-header {
328
+ --media-image: 28px;
329
+ --media-font-size: var(--font-size-level-small-2);
330
+
331
+ background-color: var(--color-1);
332
+ border-top: 1px solid var(--color-bg);
333
+ border-bottom: 1px solid var(--color-bg);
334
+ color: var(--color-bg);
335
+
336
+ display: grid;
337
+
338
+ grid-template-columns: 38px 1fr;
339
+ gap: var(--spacing-small);
340
+
341
+ &-action {
342
+ display: flex;
343
+
344
+ justify-content: center;
345
+ align-items: center;
346
+ line-height: 0;
347
+
348
+ background-color: var(--color-3);
349
+ color: var(--color-1);
350
+ aspect-ratio: 1;
351
+ }
352
+
353
+ &-item {
354
+ padding: var(--spacing-small);
355
+ }
356
+ }
357
+
358
+ .warning {
359
+ border-radius: var(--border-radius-large);
360
+ background-color: var(--color-1);
361
+ color: var(--color-2);
362
+
363
+ padding: var(--spacing-large);
364
+
365
+ display: flex;
366
+
367
+ flex-direction: column;
368
+ align-items: center;
369
+ }
370
+ .outlink {
371
+ a {
372
+ color: var(--color-2);
373
+ text-decoration: none;
374
+
375
+ &:hover,
376
+ &:focus-visible {
377
+ color: var(--color-highlight);
378
+ }
379
+ }
380
+ }
381
+ </style>