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,75 @@
1
+ <script>
2
+ import ImageLoad from './ImageLoad.svelte';
3
+
4
+ const { id, image, title, subtitle, uri, action = null } = $props();
5
+ </script>
6
+
7
+ <div class="item" data-id={id} class:withAction={!!action}>
8
+ <div class="item-image">
9
+ <ImageLoad full={image.full.url} low={image.low.url} />
10
+ </div>
11
+
12
+ <div class="item-content">
13
+ <span class="item-content-title ellipsis">{title}</span>
14
+ <span class="item-content-subtitle ellipsis color-2">{subtitle}</span>
15
+ </div>
16
+
17
+ {#if action}
18
+ <div class="item-action">
19
+ {@render action()}
20
+ </div>
21
+ {/if}
22
+ </div>
23
+
24
+ <style lang="scss">
25
+ .item {
26
+ display: grid;
27
+
28
+ align-items: center;
29
+ grid-template-areas: 'image content';
30
+ grid-template-columns: var(--media-image, 64px) 1fr;
31
+ grid-template-rows: auto;
32
+ column-gap: var(--spacing-normal);
33
+ row-gap: var(--spacing-small);
34
+
35
+ &.withAction {
36
+ grid-template-areas:
37
+ 'image content'
38
+ 'image action';
39
+ grid-template-rows: 1fr auto;
40
+ align-items: flex-start;
41
+ }
42
+
43
+ &-image {
44
+ grid-area: image;
45
+
46
+ isolation: isolate;
47
+
48
+ overflow: hidden;
49
+ border-radius: var(--border-radius-normal);
50
+
51
+ line-height: 0;
52
+
53
+ align-self: flex-start;
54
+ }
55
+
56
+ &-content {
57
+ grid-area: content;
58
+
59
+ display: flex;
60
+
61
+ flex-direction: column;
62
+
63
+ min-width: 0;
64
+ }
65
+
66
+ &-content-title,
67
+ &-content-subtitle {
68
+ font-size: var(--media-font-size, var(--font-size-body-1));
69
+ }
70
+
71
+ &-action {
72
+ grid-area: action;
73
+ }
74
+ }
75
+ </style>
@@ -0,0 +1,94 @@
1
+ <script>
2
+ import { socket } from '$lib/comms';
3
+ import { api } from '$lib/store';
4
+ import Vibrant from 'node-vibrant';
5
+ import * as DataTypes from '$server/types/data.js';
6
+
7
+ let {
8
+ playing = $bindable(null),
9
+ polling = false,
10
+ pollingTime = 5000,
11
+ activate = $bindable()
12
+ } = $props();
13
+
14
+ /** @type {{'--color-1'?: string,'--color-2'?: string,'--color-3'?: string,'--color-bg'?: string}} */
15
+ let palette = $state({});
16
+
17
+ $effect(() => {
18
+ Object.entries(palette).forEach(([prop, value]) => {
19
+ document.documentElement.style.setProperty(prop, value);
20
+ });
21
+ });
22
+
23
+ /**
24
+ *
25
+ * @param {string} image
26
+ */
27
+ async function getPalette(image) {
28
+ const img = new Image();
29
+ img.crossOrigin = 'Anonymous';
30
+ img.src = image; // + '?not-from-cache-please';
31
+
32
+ const palette = await Vibrant.from(img).getPalette();
33
+
34
+ if (!palette) {
35
+ return {};
36
+ }
37
+
38
+ return {
39
+ '--color-1': palette.LightVibrant?.hex,
40
+ '--color-2': palette.DarkVibrant?.hex,
41
+ '--color-3': palette.Muted?.hex,
42
+ '--color-bg': palette.DarkMuted?.hex,
43
+ '--color-highlight': palette.Vibrant?.hex
44
+ };
45
+ }
46
+
47
+ /**
48
+ *
49
+ * @param {DataTypes.ApiInfoResponse} newData
50
+ */
51
+ async function update(newData) {
52
+ if (newData?.track?.uri !== playing?.track?.uri) {
53
+ // is different
54
+
55
+ if (newData?.track?.normalised) {
56
+ palette = await getPalette(newData.track.normalised.image.low.url);
57
+ }
58
+ }
59
+
60
+ playing = newData;
61
+ }
62
+
63
+ async function getPlaying() {
64
+ const data = await $api.info();
65
+
66
+ update(data);
67
+ }
68
+
69
+ function automateLoop() {
70
+ const run = async () => {
71
+ await getPlaying();
72
+ int.current = window.setTimeout(run, pollingTime);
73
+ };
74
+ const int = { current: 0 };
75
+
76
+ run();
77
+
78
+ return () => {
79
+ clearTimeout(int.current);
80
+ };
81
+ }
82
+
83
+ $effect(() => {
84
+ if (polling) {
85
+ return automateLoop();
86
+ } else {
87
+ $socket?.on('info', (track) => {
88
+ update(track);
89
+ });
90
+ }
91
+
92
+ activate = getPlaying;
93
+ });
94
+ </script>
@@ -0,0 +1,80 @@
1
+ <script>
2
+ import MediaItem from './MediaItem.svelte';
3
+ import * as DataTypes from '$server/types/data.js';
4
+
5
+ /**
6
+ * @typedef {object} ResultsListProps
7
+ * @property {boolean} [expanded]
8
+ * @property {() => void} onActivate
9
+ * @property {DataTypes.ApiTrack[]} items
10
+ * @property {string} title
11
+ * @property {import('svelte').Snippet<[DataTypes.ApiNormalisedItem]>} action
12
+ */
13
+
14
+ /** @type {ResultsListProps} */
15
+ const { expanded = false, onActivate, items, title, action: ListItemAction } = $props();
16
+ </script>
17
+
18
+ <div class="results-list">
19
+ <button onclick={onActivate} class:active={expanded} class="btn-reset results-list-top"
20
+ >{title}</button
21
+ >
22
+ {#if expanded}
23
+ <div class="results-list-items">
24
+ {#each items as item}
25
+ <MediaItem {...item.normalised}>
26
+ {#snippet action()}
27
+ {@render ListItemAction(item.normalised)}
28
+ {/snippet}
29
+ </MediaItem>
30
+ {/each}
31
+ </div>
32
+ {/if}
33
+ </div>
34
+
35
+ <style lang="scss">
36
+ .results-list {
37
+ display: grid;
38
+
39
+ grid-template-rows: auto 1fr;
40
+
41
+ flex: 1;
42
+ min-height: 0;
43
+
44
+ &:not(:last-child) .results-list-items {
45
+ border-bottom: 1px solid var(--color-1);
46
+ }
47
+
48
+ &-top {
49
+ padding: var(--spacing-normal);
50
+
51
+ border-bottom: 1px solid var(--color-1);
52
+
53
+ text-align: left;
54
+
55
+ &:not(.active):hover,
56
+ &:not(.active):focus-visible {
57
+ background-color: var(--color-2);
58
+ }
59
+
60
+ &.active {
61
+ background-color: var(--color-1);
62
+ color: var(--color-bg);
63
+ }
64
+ }
65
+
66
+ &-items {
67
+ padding: var(--spacing-normal);
68
+
69
+ overflow: auto;
70
+
71
+ min-height: 0;
72
+
73
+ display: flex;
74
+
75
+ flex-direction: column;
76
+
77
+ gap: var(--spacing-normal);
78
+ }
79
+ }
80
+ </style>
@@ -0,0 +1,71 @@
1
+ <script>
2
+ import { fade, fly } from 'svelte/transition';
3
+
4
+ import { Disc, InformationCircle, Warning } from 'svelte-ionicons';
5
+
6
+ const { id, message, type = 'info', time = 2500, onEnd } = $props();
7
+
8
+ let timerId = $state(0);
9
+
10
+ $effect(() => {
11
+ return () => clearTimeout(timerId);
12
+ });
13
+
14
+ function startTimer() {
15
+ timerId = window.setTimeout(() => {
16
+ onEnd(id);
17
+ }, time);
18
+ }
19
+ </script>
20
+
21
+ <div in:fly={{ y: -50 }} out:fade onintroend={startTimer} class="item bg-color-3 color-bg">
22
+ <div class="item-icon">
23
+ {#if type === 'track'}
24
+ <Disc />
25
+ {/if}
26
+ {#if type === 'info'}
27
+ <InformationCircle />
28
+ {/if}
29
+ {#if type === 'error'}
30
+ <Warning />
31
+ {/if}
32
+ </div>
33
+
34
+ <div class="item-message size-small-2">
35
+ {@html message}
36
+ </div>
37
+ </div>
38
+
39
+ <style lang="scss">
40
+ .item {
41
+ display: grid;
42
+
43
+ grid-template-columns: 32px 1fr;
44
+
45
+ align-items: center;
46
+
47
+ gap: var(--spacing-small);
48
+
49
+ max-width: 250px;
50
+
51
+ padding: var(--spacing-small);
52
+ border-radius: var(--border-radius-normal);
53
+
54
+ &-icon {
55
+ line-height: 0;
56
+
57
+ :global(svg) {
58
+ fill: currentColor;
59
+ }
60
+ }
61
+
62
+ &-message {
63
+ padding-right: var(--spacing-normal);
64
+
65
+ :global(em) {
66
+ font-style: normal;
67
+ color: var(--color-bg);
68
+ }
69
+ }
70
+ }
71
+ </style>
@@ -0,0 +1,34 @@
1
+ <script>
2
+ import { toastItems } from '$lib/toast';
3
+ import { flip } from 'svelte/animate';
4
+ import Item from './Item.svelte';
5
+ </script>
6
+
7
+ <div class="manager">
8
+ {#each $toastItems as item (item.id)}
9
+ <div animate:flip>
10
+ <Item onEnd={toastItems.removeItem} {...item} />
11
+ </div>
12
+ {/each}
13
+ </div>
14
+
15
+ <style lang="scss">
16
+ .manager {
17
+ position: fixed;
18
+
19
+ top: 0;
20
+ left: 50%;
21
+
22
+ transform: translateX(-50%);
23
+
24
+ z-index: 10;
25
+
26
+ display: flex;
27
+
28
+ align-items: center;
29
+ flex-direction: column;
30
+ gap: var(--spacing-normal);
31
+
32
+ padding: var(--spacing-normal);
33
+ }
34
+ </style>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M256 176a80 80 0 1080 80 80.09 80.09 0 00-80-80zm0 112a32 32 0 1132-32 32 32 0 01-32 32z"/><path d="M414.39 97.61A224 224 0 1097.61 414.39 224 224 0 10414.39 97.61zM256 368a112 112 0 11112-112 112.12 112.12 0 01-112 112z"/></svg>
@@ -0,0 +1 @@
1
+ // place files you want to import through the `$lib` alias in this folder.
@@ -0,0 +1,146 @@
1
+ import { derived, writable } from 'svelte/store';
2
+ import { toastItems } from './toast';
3
+ import * as OptionsTypes from '$server/types/options';
4
+
5
+ export const liveData = writable(false);
6
+ export const authenticated = writable(false);
7
+ export const settled = writable(false);
8
+
9
+ /** @type {import("svelte/store").Writable<OptionsTypes.Config>}} */
10
+ export const config = writable({});
11
+ export const siteUrl = derived([config], ([$config]) =>
12
+ [$config?.protocol, $config?.origin].join('')
13
+ );
14
+ export const sitePort = derived([config], ([$config]) => $config.protocol);
15
+
16
+ const objectHasValues = (obj) => Object.values(obj).length > 0;
17
+
18
+ export const address = derived(
19
+ [liveData, siteUrl, sitePort],
20
+ ([$liveData, $siteUrl, $sitePort]) => {
21
+ const full = `${typeof window !== 'undefined' ? window.location.protocol : 'http:'}//${$siteUrl}:${$sitePort}`;
22
+
23
+ return {
24
+ full,
25
+ naked: [$siteUrl, $sitePort].join(':'),
26
+
27
+ /**
28
+ *
29
+ * @param {string} route
30
+ * @returns {string}
31
+ */
32
+ get(route) {
33
+ return `${full}${route}`;
34
+ },
35
+
36
+ endpoint: $liveData || !$siteUrl ? '' : `${$siteUrl}:3000`,
37
+
38
+ /**
39
+ *
40
+ * @param {string} route
41
+ * @returns {string}
42
+ */
43
+ server(route) {
44
+ return `${this.endpoint}${route}`;
45
+ }
46
+ };
47
+ }
48
+ );
49
+
50
+ export const api = derived([address], ([$address]) => {
51
+ /**
52
+ *
53
+ * @param {string} route
54
+ * @param {Record<string, any>} [data]
55
+ * @param {RequestInit} opts
56
+ */
57
+ const f = async (route, data = {}, opts = {}) => {
58
+ try {
59
+ const url = new URL(
60
+ [
61
+ $address.endpoint.startsWith('http') ? '' : window.location.origin,
62
+ $address.endpoint,
63
+ '/api',
64
+ route
65
+ ].join('')
66
+ );
67
+
68
+ if (opts.method === 'POST' && objectHasValues(data)) {
69
+ opts.body = JSON.stringify(data);
70
+ } else if (objectHasValues(data)) {
71
+ url.search = new URLSearchParams(data).toString();
72
+ }
73
+
74
+ const resp = await fetch(url, opts);
75
+ const result = await resp.json();
76
+
77
+ if (resp.status !== 200) {
78
+ throw new Error(result);
79
+ }
80
+
81
+ return result;
82
+ } catch (e) {
83
+ toastItems.addItem({
84
+ message: 'Api Error',
85
+ type: 'error'
86
+ });
87
+
88
+ throw e;
89
+ }
90
+ };
91
+
92
+ return {
93
+ health() {
94
+ return f('/health');
95
+ },
96
+ info() {
97
+ return f('/info');
98
+ },
99
+ /**
100
+ *
101
+ * @param {string} q
102
+ */
103
+ search(q) {
104
+ return f('/search', { q });
105
+ },
106
+ /**
107
+ *
108
+ * @param {string} id
109
+ */
110
+ artist(id) {
111
+ return f(`/artist/${id}`);
112
+ },
113
+ /**
114
+ *
115
+ * @param {string} id
116
+ */
117
+ album(id) {
118
+ return f(`/album/${id}`);
119
+ },
120
+
121
+ /**
122
+ *
123
+ * @param {string} uri
124
+ * @param {string} [name]
125
+ */
126
+ addTrack(uri, name) {
127
+ return f(`/add`, { uri, name });
128
+ },
129
+
130
+ play() {
131
+ return f('/play');
132
+ },
133
+
134
+ pause() {
135
+ return f('/pause');
136
+ },
137
+
138
+ skipForward() {
139
+ return f('/skipForward');
140
+ },
141
+
142
+ skipBackward() {
143
+ return f('/skipBackward');
144
+ }
145
+ };
146
+ });
@@ -0,0 +1,166 @@
1
+
2
+ :root {
3
+ --font-family: system-ui;
4
+
5
+ --font-weight-headline-bold: 900;
6
+ --font-weight-headline: 700;
7
+ --font-weight-body: 600;
8
+
9
+ --font-size-level-headline-1: 36px;
10
+ --font-size-level-headline-2: 28px;
11
+ --font-size-level-headline-3: 21px;
12
+ --font-size-level-body: 16px;
13
+ --font-size-level-small-1: 10px;
14
+ --font-size-level-small-2: 12px;
15
+
16
+ --easing: cubic-bezier(0.4, 0, 0.2, 1);
17
+
18
+ --color-bg: white;
19
+ --color-1: black;
20
+ --color-2: grey;
21
+ --color-3: lightgrey;
22
+ --color-highlight: blue;
23
+
24
+ --border-radius-large: 10px;
25
+ --border-radius-normal: 5px;
26
+
27
+ --spacing-x-large: 50px;
28
+ --spacing-large: 20px;
29
+ --spacing-normal: 10px;
30
+ --spacing-small: 5px;
31
+
32
+ -ms-text-size-adjust: 100%;
33
+ -webkit-text-size-adjust: 100%;
34
+ -moz-osx-font-smoothing: grayscale;
35
+ -webkit-font-smoothing: antialiased;
36
+ }
37
+
38
+ *,
39
+ *:before,
40
+ *:after {
41
+ box-sizing: border-box;
42
+ }
43
+
44
+ html,
45
+ body {
46
+ font-size: var(--font-size-normal);
47
+ font-family: var(--font-family);
48
+ font-weight: var(--font-weight-body);
49
+ line-height: 1.2;
50
+ }
51
+
52
+ h1,
53
+ .headline-1 {
54
+ font-size: var(--font-size-level-headline-1);
55
+ font-weight: var(--font-weight-headline-bold);
56
+ margin: 0;
57
+ line-height: 1;
58
+ }
59
+
60
+ h2,
61
+ .headline-2 {
62
+ font-size: var(--font-size-level-headline-2);
63
+ font-weight: var(--font-weight-headline);
64
+ margin: 0;
65
+ line-height: 1;
66
+ }
67
+
68
+ h3,
69
+ .headline-3 {
70
+ font-size: var(--font-size-level-headline-3);
71
+ font-weight: var(--font-weight-headline);
72
+ margin: 0;
73
+ line-height: 1;
74
+ }
75
+
76
+ p a {
77
+ text-decoration: none;
78
+ color: var(--color-highlight);
79
+
80
+ &:hover,
81
+ &:focus-visible {
82
+ opacity: 0.75;
83
+ }
84
+ }
85
+
86
+ img {
87
+ max-width: 100%;
88
+ }
89
+
90
+ .btn-reset {
91
+ outline: none;
92
+ border: none;
93
+
94
+ padding: 0;
95
+ margin: 0;
96
+
97
+ text-transform: inherit;
98
+
99
+ cursor: pointer;
100
+
101
+ background-color: transparent;
102
+ font-weight: inherit;
103
+ color: inherit;
104
+ }
105
+
106
+ .color-bg {
107
+ color: var(--color-bg);
108
+ }
109
+ .color-1 {
110
+ color: var(--color-1);
111
+ }
112
+ .color-2 {
113
+ color: var(--color-2);
114
+ }
115
+ .color-3 {
116
+ color: var(--color-3);
117
+ }
118
+
119
+ .bg-color-bg {
120
+ background-color: var(--color-bg);
121
+ }
122
+ .bg-color-1 {
123
+ background-color: var(--color-1);
124
+ }
125
+ .bg-color-2 {
126
+ background-color: var(--color-2);
127
+ }
128
+ .bg-color-3 {
129
+ background-color: var(--color-3);
130
+ }
131
+
132
+
133
+ .size-headline-1 {
134
+ font-size: var(--font-size-level-headline-1);
135
+ }
136
+
137
+ .size-headline-2 {
138
+ font-size: var(--font-size-level-headline-2);
139
+ }
140
+
141
+ .size-headline-3 {
142
+ font-size: var(--font-size-level-headline-3);
143
+ }
144
+
145
+ .size-body {
146
+ font-size: var(--font-size-level-body);
147
+ }
148
+
149
+ .size-small-1 {
150
+ font-size: var(--font-size-level-small-1);
151
+ }
152
+
153
+ .size-small-2 {
154
+ font-size: var(--font-size-level-small-2);
155
+ }
156
+
157
+ .ellipsis {
158
+ white-space: nowrap;
159
+ overflow: hidden;
160
+ text-overflow: ellipsis;
161
+ }
162
+
163
+
164
+ .idle {
165
+ cursor: none;
166
+ }