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,94 @@
1
+ /**
2
+ * @typedef {object} SpotifyImageItem
3
+ * @property {string} url
4
+ * @property {number} width
5
+ * @property {number} height
6
+ */
7
+ /**
8
+ * @typedef {object} ApiImageDict
9
+ * @property {SpotifyImageItem} full
10
+ * @property {SpotifyImageItem} low
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} ApiNormalisedItem
15
+ * @property {string} id
16
+ * @property {string} title
17
+ * @property {string} subtitle
18
+ * @property {string} uri
19
+ * @property {ApiImageDict} image
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} ApiTrackItem
24
+ * @property {string} title
25
+ * @property {string} album
26
+ * @property {string} artist
27
+ * @property {string[]} artists
28
+ * @property {number} number
29
+ */
30
+
31
+ /**
32
+ * @typedef {object} ApiPlaylistItem
33
+ * @property {string} title
34
+ * @property {string} owner
35
+ * @property {number} total
36
+ */
37
+
38
+ /**
39
+ * @typedef {object} ApiArtistItem
40
+ * @property {string} title
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} ApiAlbumItem
45
+ * @property {string} title
46
+ * @property {string} release
47
+ * @property {number} total
48
+ */
49
+
50
+ /**
51
+ * @typedef {object} ApiEpisodeItem
52
+ * @property {string} title
53
+ * @property {string} show
54
+ * @property {string} release
55
+ */
56
+
57
+ /**
58
+ * @typedef {object} ApiShowItem
59
+ * @property {string} title
60
+ */
61
+
62
+ /**
63
+ * @typedef {object} ApiStandardItem
64
+ * @property {string} id
65
+ * @property {string} uri
66
+ * @property {ApiImageDict} image
67
+ * @property {ApiNormalisedItem} normalised
68
+ */
69
+
70
+ /**
71
+ * @typedef {(ApiTrackItem | ApiEpisodeItem) & ApiStandardItem} ApiTrack
72
+ */
73
+
74
+ /**
75
+ * @typedef {{ type: "playlist"} & ApiPlaylistItem & ApiStandardItem | { type: "album"} & ApiAlbumItem & ApiStandardItem | { type: "artist"} & ApiArtistItem & ApiStandardItem | { type: "show"} & ApiShowItem & ApiStandardItem} ApiContext
76
+ */
77
+
78
+ /**
79
+ * @typedef {object} ApiInfoResponse
80
+ * @property {boolean} isPlaying
81
+ * @property {ApiTrack} track
82
+ * @property {ApiContext} context
83
+ * @property {{ current: number, duration: number }} playing
84
+ *
85
+ */
86
+
87
+ /**
88
+ * @typedef {object} ApiSearchResponse
89
+ * @property {ApiTrack[]} tracks
90
+ * @property {ApiTrack[]} artists
91
+ * @property {ApiTrack[]} albums
92
+ */
93
+
94
+ export const Types = {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @typedef {object} SpotifyAccessToken
3
+ * @property {string} refresh_token
4
+ * @property {string} access_token
5
+ * @property {string} token_type
6
+ * @property {number} expires_in
7
+ * @property {string} scope
8
+ */
9
+
10
+ /**
11
+ * @typedef {object} CommsItem
12
+ * @property {string} message
13
+ * @property {'info' | 'track' | 'error'} type
14
+ */
15
+
16
+ export const Types = {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @typedef {object} ApiOptions
3
+ * @property {import('@spotify/web-api-ts-sdk').Market} market If you want to change the market in which many operations happen in
4
+ * @property {number} searchQueryLimit The max amount of tracks to return in any search request
5
+ * @property {boolean} centralisedPolling The server will poll for the spotify info, instead of all of the clients doing it, to reduce chance of rate limit
6
+ * @property {number} centralisedPollingTimer The interval for polling
7
+ * @property {boolean} canAdd Can add to the queue
8
+ * @property {boolean} canControl Can control the playback
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} SpotifyOptions
13
+ * @property {string} client_id
14
+ * @property {string} client_secret
15
+ * @property {string} routePrefix The prefixed sub route that the spotify mounting sits on
16
+ * @property {string} routeToken The route in which spotify redirects to do the exchange of code for access token
17
+ * @property {string} authenticatedRedirect The route to redirect to after the app is authenticated
18
+ * @property {string} accessTokenJsonLocation The path to save the access token file, for caching
19
+ * @property {string[]} scope The scope
20
+ *
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} PluginItemInject
25
+ * @property {import('socket.io').Server} io
26
+ * @property {import('./comms.js').Comms} comms
27
+ * @property {import('express').Express} app
28
+ * @property {import('../events').AppEventEmitter} events
29
+ * @property {{current: null | import('@spotify/web-api-ts-sdk').SpotifyApi}} sdk
30
+ * @property {import('../api/interact.js').SpotifyInteract} spotify
31
+ * @property {SpotifyAmbientDisplayOptions} config
32
+ * @property {import('../history.js').CommandHistory} history
33
+ * @property {{ url: string, player: string }} info
34
+ *
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} PluginItem
39
+ * @property {boolean} [skip] Whether or not to skip mounting this plugin. Useful for dev/prod switch
40
+ * @property {string} [name] Plugin name, useful for internals
41
+ * @property {(inject: PluginItemInject) => void} handler
42
+ */
43
+
44
+ /**
45
+ * @typedef {object} SpotifyAmbientDisplayOptions
46
+ * @property {number} port The root url of the app, used for the redirect_uri passed to spotify
47
+ * @property {string} origin
48
+ * @property {string} protocol
49
+ * @property {string} playerRoute
50
+ * @property {boolean} verbose
51
+ * @property {ApiOptions} api
52
+ * @property {SpotifyOptions} spotify
53
+ * @property {Record<string, any>} pluginOptions
54
+ * @property {string[]} suppressErrors Any error events to catch and not send to the frontend, because perhaps a plugin will handle them
55
+ */
56
+
57
+ /**
58
+ * @typedef {object} Config
59
+ * @property {number} [port]
60
+ * @property {string} [origin]
61
+ * @property {string} [protocol]
62
+ * @property {string} [playerRoute]
63
+ * @property {boolean} [verbose]
64
+ * @property {Partial<ApiOptions>} [api]
65
+ * @property {Partial<SpotifyOptions>} [spotify]
66
+ * @property {PluginItem[]} [plugins]
67
+ * @property {Record<string, any>} [pluginOptions]
68
+ * @property {string[]} [suppressErrors] Any error events to catch and not send to the frontend, because perhaps a plugin will handle them
69
+ *
70
+ */
71
+
72
+ export const Types = {};
@@ -0,0 +1,101 @@
1
+ import os from 'node:os';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ /**
5
+ *
6
+ * @param {string} url
7
+ * @returns
8
+ */
9
+ export const __dirname = (url) => {
10
+ return fileURLToPath(new URL('.', url));
11
+ };
12
+
13
+ const timer = (time) => {
14
+ return new Promise((resolve) => setTimeout(resolve, time));
15
+ };
16
+
17
+ /**
18
+ *
19
+ * @param {() => Promise<any>} asyncInteralFunc
20
+ * @param {number} rate
21
+ * @returns
22
+ */
23
+ export const asyncInterval = (asyncInteralFunc, rate) => {
24
+ /** @type {undefined | ReturnType<typeof setTimeout>} */
25
+ let timerId;
26
+
27
+ const run = async () => {
28
+ await asyncInteralFunc();
29
+
30
+ timerId = setTimeout(run, rate);
31
+ };
32
+
33
+ run();
34
+
35
+ return () => {
36
+ clearTimeout(timerId);
37
+ };
38
+ };
39
+
40
+ export const getIp = () => {
41
+ const [item] = Object.values(os.networkInterfaces())
42
+ .flat()
43
+ .filter((item) => item && item.family === 'IPv4' && !item.internal);
44
+ return item?.address;
45
+ };
46
+
47
+ /**
48
+ *
49
+ * @param {string} url
50
+ */
51
+ export const isMain = (url) => {
52
+ const runner = !process.argv[1].endsWith('.js')
53
+ ? [process.argv[1], 'index.js'].join('/')
54
+ : process.argv[1];
55
+
56
+ return runner === fileURLToPath(url);
57
+ };
58
+
59
+ /**
60
+ *
61
+ * @param {() => void} catchAndRetryFunc
62
+ * @param {{ times?: number, validateError?: () => boolean, backoff?: number}} opts
63
+ */
64
+ export const catchAndRetry = async (
65
+ catchAndRetryFunc,
66
+ { times = 10, validateError = () => true, backoff = 1000 } = {}
67
+ ) => {
68
+ return new Promise((resolve, reject) => {
69
+ const run = async (retry = 0) => {
70
+ try {
71
+ const resp = await catchAndRetryFunc();
72
+ resolve(resp);
73
+ } catch (e) {
74
+ if (validateError(e) && retry < times) {
75
+ await timer(backoff);
76
+ run(retry + 1);
77
+ } else {
78
+ reject(e);
79
+ }
80
+ }
81
+ };
82
+
83
+ run();
84
+ });
85
+ };
86
+
87
+ /**
88
+ *
89
+ * @param {string} filePath
90
+ */
91
+ export const expandAliases = (filePath) => {
92
+ if (filePath.startsWith('~')) {
93
+ return os.homedir() + filePath.slice(0);
94
+ }
95
+
96
+ if (filePath.startsWith('$HOME')) {
97
+ return os.homedir() + filePath.slice('$HOME'.length);
98
+ }
99
+
100
+ return filePath;
101
+ };
package/src/app.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ // See https://svelte.dev/docs/kit/types#app.d.ts
2
+ // for information about these interfaces
3
+ declare global {
4
+ namespace App {
5
+ // interface Error {}
6
+ // interface Locals {}
7
+ // interface PageData {}
8
+ // interface PageState {}
9
+ // interface Platform {}
10
+ }
11
+ }
12
+
13
+ export {};
package/src/app.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ %sveltekit.head%
8
+ </head>
9
+ <body data-sveltekit-preload-data="hover">
10
+ <div style="display: contents">%sveltekit.body%</div>
11
+ </body>
12
+ </html>
@@ -0,0 +1,23 @@
1
+ import QRCode from 'qrcode-svg';
2
+
3
+ export const qr = (node, { url, ...rest }) => {
4
+ // the node has been mounted in the DOM
5
+
6
+ $effect(() => {
7
+ var svg = new QRCode({
8
+ color: 'currentColor',
9
+ background: 'transparent',
10
+ padding: 0,
11
+ join: true,
12
+ container: 'svg-viewbox',
13
+ ...rest,
14
+ content: url
15
+ }).svg();
16
+
17
+ node.innerHTML = svg;
18
+
19
+ return () => {
20
+ node.innerHTML = '';
21
+ };
22
+ });
23
+ };
@@ -0,0 +1,25 @@
1
+ import { io, Socket } from 'socket.io-client';
2
+ import { derived } from 'svelte/store';
3
+ import { address, settled } from './store';
4
+
5
+ /** */
6
+ const previous = {
7
+ /** @type {Socket | null} */
8
+ current: null
9
+ };
10
+
11
+ export const socket = derived([address, settled], ([$address, $settled]) => {
12
+ if (!$settled) {
13
+ return null;
14
+ }
15
+
16
+ const i = io($address.endpoint);
17
+
18
+ if (previous.current) {
19
+ previous.current.disconnect();
20
+ }
21
+
22
+ previous.current = i;
23
+
24
+ return i;
25
+ });
@@ -0,0 +1,74 @@
1
+ <script>
2
+ import { qr } from '$lib/actions/qr.svelte';
3
+ import { address } from '$lib/store';
4
+ </script>
5
+
6
+ <div class="content">
7
+ <div class="options">
8
+ <div class="options-left">
9
+ <h1>This instance is not authenticated</h1>
10
+ <p>
11
+ {$address.server('/spotify/start')}
12
+ <a href={$address.server('/spotify/start')}>Authenticate</a> or scan qr to authenticate via device
13
+ </p>
14
+ </div>
15
+ <div class="options-right">
16
+ <span class="qr" use:qr={{ url: $address.server('/spotify/start') }}></span>
17
+ </div>
18
+ <div class="options-bottom">
19
+ <p class="size-small-1 color-2">
20
+ Make sure that <em>{$address.server('/spotify/token')}</em> is added as a Redirect URI in your
21
+ spotify app dashboard
22
+ </p>
23
+ </div>
24
+ </div>
25
+ </div>
26
+
27
+ <style lang="scss">
28
+ .content {
29
+ position: absolute;
30
+
31
+ top: 50%;
32
+ left: 50%;
33
+
34
+ width: calc(100% - (var(--spacing-large) * 2));
35
+ max-width: 500px;
36
+ padding: var(--spacing-large);
37
+
38
+ transform: translate3d(-50%, -50%, 0);
39
+
40
+ background-color: var(--color-3);
41
+ color: var(--color-1);
42
+
43
+ border-radius: var(--border-radius-large);
44
+
45
+ p {
46
+ margin: 0.5em 0;
47
+ max-width: 65%;
48
+ }
49
+
50
+ .qr {
51
+ display: block;
52
+ }
53
+ }
54
+
55
+ .options {
56
+ display: grid;
57
+
58
+ grid-template-columns: 1fr 100px;
59
+ gap: var(--spacing-normal);
60
+
61
+ &-bottom {
62
+ grid-column: 1 / -1;
63
+
64
+ p {
65
+ margin: 0;
66
+
67
+ em {
68
+ color: vaR(--color-1);
69
+ font-style: normal;
70
+ }
71
+ }
72
+ }
73
+ }
74
+ </style>
@@ -0,0 +1,91 @@
1
+ <script>
2
+ import { Pause, Play, PlaySkipBack, PlaySkipForward } from 'svelte-ionicons';
3
+ import { api } from '$lib/store';
4
+
5
+ const { playing = false, title, onTrigger = () => {}, canControl = true } = $props();
6
+
7
+ /**
8
+ * A variable to create the illusion of immediate feedback
9
+ *
10
+ * @type {boolean | null}
11
+ */
12
+ let eagerToggle = $state(null);
13
+
14
+ let playDisplay = $derived(eagerToggle ?? playing);
15
+
16
+ async function onSkipBackward() {
17
+ await $api.skipBackward();
18
+ onTrigger();
19
+ }
20
+
21
+ async function onPlay() {
22
+ eagerToggle = true;
23
+ await $api.play();
24
+ onTrigger();
25
+ }
26
+
27
+ async function onPause() {
28
+ eagerToggle = false;
29
+ await $api.pause();
30
+ onTrigger();
31
+ }
32
+
33
+ async function onSkipForward() {
34
+ await $api.skipForward();
35
+ onTrigger();
36
+ }
37
+
38
+ $effect(() => {
39
+ if (typeof playing === 'boolean') {
40
+ eagerToggle = null;
41
+ }
42
+ });
43
+ </script>
44
+
45
+ <div class="controls color-1">
46
+ <div class="controls-top ellipsis">{title}</div>
47
+
48
+ {#if canControl}
49
+ <div class="controls-actions">
50
+ <button onclick={onSkipBackward} class="btn-reset">
51
+ <PlaySkipBack />
52
+ </button>
53
+ {#if playDisplay === true}
54
+ <button onclick={onPause} class="btn-reset">
55
+ <Pause />
56
+ </button>
57
+ {:else if playDisplay === false}
58
+ <button onclick={onPlay} class="btn-reset">
59
+ <Play />
60
+ </button>
61
+ {/if}
62
+ <button onclick={onSkipForward} class="btn-reset">
63
+ <PlaySkipForward />
64
+ </button>
65
+ </div>
66
+ {/if}
67
+ </div>
68
+
69
+ <style lang="scss">
70
+ .controls {
71
+ display: flex;
72
+
73
+ flex-direction: column;
74
+ align-items: center;
75
+
76
+ gap: var(--spacing-normal);
77
+
78
+ width: 100%;
79
+ max-width: 400px;
80
+
81
+ &-top {
82
+ width: 100%;
83
+
84
+ text-align: center;
85
+ }
86
+
87
+ :global(svg) {
88
+ fill: currentColor;
89
+ }
90
+ }
91
+ </style>
@@ -0,0 +1,79 @@
1
+ <script>
2
+ const { full, low } = $props();
3
+
4
+ const LOAD_STATE = {
5
+ INITIAL: 0,
6
+ LOAD: 1,
7
+ FULL: 2,
8
+ COMPLETE: 3
9
+ };
10
+
11
+ let loadState = $state();
12
+
13
+ function onLowLoad() {
14
+ if (loadState === LOAD_STATE.INITIAL) {
15
+ loadState = LOAD_STATE.LOAD;
16
+ }
17
+ }
18
+
19
+ function onFullLoad() {
20
+ loadState = LOAD_STATE.FULL;
21
+ }
22
+
23
+ let mounted = $state(false);
24
+
25
+ $effect(() => {
26
+ mounted = true;
27
+ });
28
+ </script>
29
+
30
+ <span
31
+ class="image"
32
+ class:display={loadState === LOAD_STATE.FULL || loadState === LOAD_STATE.COMPLETE}
33
+ >
34
+ {#if loadState !== LOAD_STATE.INITIAL && mounted}
35
+ <img
36
+ class="full"
37
+ src={full}
38
+ onload={onFullLoad}
39
+ alt=""
40
+ ontransitionend={() => (loadState = LOAD_STATE.COMPLETE)}
41
+ />
42
+ {/if}
43
+
44
+ {#if loadState !== LOAD_STATE.COMPLETE}
45
+ <img class="low" src={low} onload={onLowLoad} alt="" />
46
+ {/if}
47
+ </span>
48
+
49
+ <style lang="scss">
50
+ .image {
51
+ display: inline-grid;
52
+
53
+ grid-template-columns: 1fr;
54
+ grid-template-rows: 1fr;
55
+
56
+ line-height: 0;
57
+
58
+ width: 100%;
59
+ height: 100%;
60
+
61
+ img {
62
+ grid-column: 1;
63
+ grid-row: 1;
64
+ }
65
+ }
66
+
67
+ .full {
68
+ opacity: 0;
69
+
70
+ transition: {
71
+ duration: 0.5s;
72
+ property: opacity;
73
+ }
74
+
75
+ .display & {
76
+ opacity: 1;
77
+ }
78
+ }
79
+ </style>
@@ -0,0 +1,75 @@
1
+ <script>
2
+ const { floating = false } = $props();
3
+ </script>
4
+
5
+ <div class="loading" class:floating>
6
+ <div class="loading-icon"></div>
7
+ <div class="loading-icon"></div>
8
+ <div class="loading-icon"></div>
9
+ </div>
10
+
11
+ <style lang="scss">
12
+ @property --loading-size {
13
+ syntax: '<length>';
14
+ inherits: false;
15
+ initial-value: 16px;
16
+ }
17
+
18
+ .loading {
19
+ display: inline-grid;
20
+
21
+ aspect-ratio: 1;
22
+ align-items: center;
23
+ grid-template-columns: repeat(3, var(--loading-size));
24
+
25
+ padding: var(--loading-padding, var(--spacing-small));
26
+ gap: var(--loading-gap, var(--spacing-small));
27
+
28
+ background-color: var(--color-1);
29
+ border-radius: var(--border-radius-normal);
30
+
31
+ &.floating {
32
+ position: absolute;
33
+
34
+ top: 50%;
35
+ left: 50%;
36
+
37
+ transform: translate3d(-50%, -50%, 0);
38
+
39
+ z-index: 20;
40
+ }
41
+ }
42
+
43
+ .loading-icon {
44
+ background-color: var(--color-bg);
45
+
46
+ aspect-ratio: 1;
47
+
48
+ border-radius: 100%;
49
+
50
+ animation: {
51
+ name: BLOAT;
52
+ duration: 1.2s;
53
+ iteration-count: infinite;
54
+ timing-function: var(--easing);
55
+ }
56
+
57
+ @for $i from 1 through 3 {
58
+ &:nth-child(#{$i}) {
59
+ animation-delay: 0.15s * ($i - 1);
60
+ }
61
+ }
62
+ }
63
+
64
+ @keyframes BLOAT {
65
+ 0% {
66
+ transform: scale(1);
67
+ }
68
+ 50% {
69
+ transform: scale(1.2);
70
+ }
71
+ 100% {
72
+ transform: scale(1);
73
+ }
74
+ }
75
+ </style>