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.
- package/.nvmrc +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +17 -0
- package/CHANGELOG.md +34 -0
- package/README.md +84 -0
- package/ambient.config.js.template +13 -0
- package/env.template +2 -0
- package/eslint.config.js +24 -0
- package/jsconfig.json +19 -0
- package/package.json +67 -0
- package/screenshot.png +0 -0
- package/server/api/index.js +213 -0
- package/server/api/interact.js +234 -0
- package/server/api/utils.js +284 -0
- package/server/comms.js +13 -0
- package/server/config.js +27 -0
- package/server/constants.js +52 -0
- package/server/events.js +41 -0
- package/server/history.js +47 -0
- package/server/index.js +155 -0
- package/server/logs.js +15 -0
- package/server/memo.js +63 -0
- package/server/run.js +5 -0
- package/server/spotify/auth.js +98 -0
- package/server/spotify/index.js +105 -0
- package/server/spotify/sdk.js +217 -0
- package/server/types/comms.js +7 -0
- package/server/types/data.js +94 -0
- package/server/types/index.js +16 -0
- package/server/types/options.js +72 -0
- package/server/utils.js +101 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +12 -0
- package/src/lib/actions/qr.svelte.js +23 -0
- package/src/lib/comms.js +25 -0
- package/src/lib/components/AuthenticateTrigger.svelte +74 -0
- package/src/lib/components/Controls.svelte +91 -0
- package/src/lib/components/ImageLoad.svelte +79 -0
- package/src/lib/components/LoadingIndicator.svelte +75 -0
- package/src/lib/components/MediaItem.svelte +75 -0
- package/src/lib/components/PlayingTracker.svelte +94 -0
- package/src/lib/components/ResultsList.svelte +80 -0
- package/src/lib/components/Toast/Item.svelte +71 -0
- package/src/lib/components/Toast/Manager.svelte +34 -0
- package/src/lib/icons/disc.svg +1 -0
- package/src/lib/index.js +1 -0
- package/src/lib/store.js +146 -0
- package/src/lib/styles.scss +166 -0
- package/src/lib/toast.js +57 -0
- package/src/lib/utils.js +723 -0
- package/src/routes/+layout.server.js +25 -0
- package/src/routes/+layout.svelte +72 -0
- package/src/routes/+page.svelte +381 -0
- package/src/routes/player/+page.svelte +294 -0
- package/static/favicon.ico +0 -0
- package/static/favicon.png +0 -0
- package/static/icons/144.favicon.png +0 -0
- package/static/icons/168.favicon.png +0 -0
- package/static/icons/192.favicon.png +0 -0
- package/static/icons/48.favicon.png +0 -0
- package/static/icons/72.favicon.png +0 -0
- package/static/icons/96.favicon.png +0 -0
- package/static/manifest.json +40 -0
- package/svelte.config.js +19 -0
- package/tools/BuildManifest.js +87 -0
- 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>
|