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,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 = {};
|
package/server/utils.js
ADDED
|
@@ -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
|
+
};
|
package/src/lib/comms.js
ADDED
|
@@ -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>
|