@axium/client 0.23.6 → 0.24.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/dist/locales.d.ts CHANGED
@@ -266,7 +266,7 @@ export interface ReplacementOptions {
266
266
  }
267
267
  type _ArgsValue<V extends string[]> = UnionToIntersection<{
268
268
  [I in keyof V]: Split<V[I], '}'> extends [infer Name extends string, string] ? {
269
- [N in Name]: string;
269
+ [N in Name]: string | number | bigint | boolean;
270
270
  } : {};
271
271
  }[keyof V & number]>;
272
272
  type Replacements<K extends string> = ReplacementOptions & (GetByString<Locale, K> extends string ? _ArgsValue<Split<GetByString<Locale, K> & string, '{'>> : Record<string, any>);
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { text } from '@axium/client/locales';
3
+ import type { Snippet } from 'svelte';
4
+ import Icon from './Icon.svelte';
5
+ import MediaControls from './MediaControls.svelte';
6
+ import { getMetadata, MediaState, type MediaProps } from './media.svelte.js';
7
+
8
+ interface Props extends MediaProps {
9
+ cover?: boolean;
10
+ extraControls?: Snippet;
11
+ }
12
+
13
+ const { cover: showCover, extraControls, ...rest }: Props = $props();
14
+ const { src } = rest;
15
+
16
+ const id = $props.id();
17
+
18
+ const { metadata, picture, pictureURL } = await getMetadata(rest);
19
+
20
+ const audioInfo = [
21
+ ['music', metadata.common.title],
22
+ ['album', metadata.common.album],
23
+ ['user-music', metadata.common.artist],
24
+ ['hashtag', metadata.common.track.no],
25
+ ['compact-disc', metadata.common.disk.no],
26
+ ] as const;
27
+
28
+ const media = new MediaState();
29
+ </script>
30
+
31
+ <div onkeydown={media.keydown}>
32
+ {#if showCover}
33
+ <div class="audio-cover" onclick={media.click}>
34
+ {#if picture}
35
+ <img src={pictureURL} alt={picture.description} />
36
+ {:else}
37
+ <Icon i="music-note" --size="50px" />
38
+ {/if}
39
+ </div>
40
+ {/if}
41
+
42
+ <audio
43
+ {src}
44
+ bind:currentTime={media.currentTime}
45
+ bind:duration={media.duration}
46
+ bind:volume={media.volume}
47
+ bind:paused={media.paused}
48
+ bind:muted={media.muted}
49
+ bind:buffered={media.buffered}
50
+ bind:playbackRate={media.playbackRate}
51
+ bind:ended={media.ended}
52
+ ></audio>
53
+ <MediaControls {media}>
54
+ {#if extraControls}{@render extraControls()}{/if}
55
+ <button class="reset icon-text" command="show-modal" commandfor="{id}:audio-info">
56
+ <Icon i="regular/circle-info" />
57
+ </button>
58
+ </MediaControls>
59
+ </div>
60
+
61
+ <dialog id="{id}:audio-info">
62
+ <div class="audio-info">
63
+ {#each audioInfo as [icon, value]}
64
+ {#if value}
65
+ <div class="icon-text">
66
+ <Icon i={icon} />
67
+ <span>{value}</span>
68
+ </div>
69
+ {/if}
70
+ {/each}
71
+ </div>
72
+ <button command="close" commandfor="{id}:audio-info">{text('generic.done')}</button>
73
+ </dialog>
74
+
75
+ <style>
76
+ .audio-cover {
77
+ width: 512px;
78
+ height: 512px;
79
+ background-color: var(--bg-alt);
80
+ border-radius: 1em;
81
+ display: flex;
82
+ flex-direction: column;
83
+ gap: 1em;
84
+ align-items: center;
85
+ justify-content: center;
86
+
87
+ img {
88
+ border-radius: 1em;
89
+ }
90
+
91
+ :global(.MediaControls) {
92
+ width: 512px;
93
+ }
94
+ }
95
+
96
+ .audio-info {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 0.5em;
100
+ margin-bottom: 1em;
101
+ }
102
+ </style>
package/lib/Icon.svelte CHANGED
@@ -5,7 +5,7 @@
5
5
  const href = $derived(`/icons/${style}.svg#${id}`);
6
6
  </script>
7
7
 
8
- <svg class="Icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" {...rest}>
8
+ <svg {...rest} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="1em" height="1em" class={['Icon', rest.class]}>
9
9
  <use {href} />
10
10
  </svg>
11
11
 
@@ -0,0 +1,200 @@
1
+ <script lang="ts">
2
+ import { formatDuration } from '@axium/core';
3
+ import Icon from './Icon.svelte';
4
+ import type { MediaState } from './media.svelte.js';
5
+ import type { Snippet } from 'svelte';
6
+
7
+ interface Props {
8
+ media: MediaState;
9
+ children?: Snippet;
10
+ }
11
+
12
+ const { media, children }: Props = $props();
13
+ </script>
14
+
15
+ <div class="MediaControls">
16
+ <button class="reset icon-text" onclick={media.click}>
17
+ <Icon i={media.ended ? 'arrow-rotate-right' : media.paused ? 'play' : 'pause'} />
18
+ </button>
19
+ <div class="timeline">
20
+ <div class="timeline-track">
21
+ {#each media.buffered || [] as { start, end }}
22
+ <div
23
+ class="buffered-range"
24
+ style:left="{(start / (media.duration || 1)) * 100}%"
25
+ style:width="{((end - start) / (media.duration || 1)) * 100}%"
26
+ ></div>
27
+ {/each}
28
+ <div class="played-range" style:width="{(media.currentTime / (media.duration || 1)) * 100}%"></div>
29
+ </div>
30
+ <div class="timeline-thumb" style:left="{(media.currentTime / (media.duration || 1)) * 100}%">
31
+ <Icon i="pipe" />
32
+ </div>
33
+ <input class="seek-input" type="range" min="0" max={media.duration || 1} step="0.01" bind:value={media.currentTime} />
34
+ </div>
35
+ <div class="times">
36
+ <span>{formatDuration(media.currentTime)}</span>
37
+ <span>/</span>
38
+ <span>{formatDuration(media.duration)}</span>
39
+ </div>
40
+ <button class="reset icon-text volume" onclick={() => (media.muted = !media.muted)}>
41
+ <Icon i={media.muted || !media.volume ? 'volume-slash' : 'volume'} />
42
+ </button>
43
+ <div class="volume-popup">
44
+ <div class="volume-track">
45
+ <div class="volume-fill" style:height="{media.volume * 100}%"></div>
46
+ <div class="volume-thumb" style:bottom="{media.volume * 100}%"></div>
47
+ </div>
48
+ <input class="volume-input" type="range" min="0" max="1" step="0.01" bind:value={media.volume} />
49
+ </div>
50
+ {#if children}{@render children()}{/if}
51
+ </div>
52
+
53
+ <style>
54
+ .MediaControls {
55
+ display: flex;
56
+ gap: 1em;
57
+ align-items: center;
58
+ padding: 1em;
59
+ border-radius: 0.75em;
60
+ background-color: var(--bg-menu);
61
+
62
+ > :not(.timeline) {
63
+ flex: 0 0 auto;
64
+ }
65
+
66
+ .timeline {
67
+ position: relative;
68
+ flex: 1 1 auto;
69
+ height: 8px;
70
+ display: flex;
71
+ align-items: center;
72
+ }
73
+
74
+ .timeline-track {
75
+ position: absolute;
76
+ inset: 0;
77
+ background-color: var(--bg-normal);
78
+ border-radius: 4px;
79
+ overflow: hidden;
80
+ pointer-events: none;
81
+ }
82
+
83
+ .buffered-range {
84
+ position: absolute;
85
+ top: 0;
86
+ bottom: 0;
87
+ background-color: var(--bg-alt);
88
+ }
89
+
90
+ .played-range {
91
+ position: absolute;
92
+ top: 0;
93
+ bottom: 0;
94
+ left: 0;
95
+ background-color: var(--bg-strong);
96
+ }
97
+
98
+ .timeline-thumb {
99
+ position: absolute;
100
+ top: 50%;
101
+ transform: translate(-50%, -50%);
102
+ pointer-events: none;
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ }
107
+
108
+ .seek-input {
109
+ position: absolute;
110
+ inset: 0;
111
+ width: 100%;
112
+ height: 100%;
113
+ margin: 0;
114
+ opacity: 0;
115
+ cursor: pointer;
116
+ }
117
+
118
+ button.volume {
119
+ anchor-name: --volume-btn;
120
+ }
121
+
122
+ .volume-popup {
123
+ position: absolute;
124
+ position-anchor: --volume-btn;
125
+ bottom: anchor(top);
126
+ left: anchor(center);
127
+ transform: translateX(-50%);
128
+ margin-bottom: 0.5em;
129
+ background-color: var(--bg-menu);
130
+ border-radius: 0.75em;
131
+ padding: 1em 0;
132
+ height: 100px;
133
+ width: 40px;
134
+ display: none;
135
+ align-items: center;
136
+ justify-content: center;
137
+ box-shadow: 0 4px 12px #0001;
138
+ z-index: 10;
139
+ }
140
+
141
+ .volume-popup::after {
142
+ content: '';
143
+ position: absolute;
144
+ top: 100%;
145
+ left: 0;
146
+ right: 0;
147
+ height: 0.5em;
148
+ }
149
+
150
+ button.volume:hover ~ .volume-popup,
151
+ .volume-popup:hover,
152
+ .volume-popup:focus-within {
153
+ display: flex;
154
+ }
155
+
156
+ .volume-track {
157
+ position: relative;
158
+ width: 6px;
159
+ height: 100%;
160
+ background-color: var(--bg-alt);
161
+ border-radius: 3px;
162
+ pointer-events: none;
163
+ }
164
+
165
+ .volume-fill {
166
+ position: absolute;
167
+ bottom: 0;
168
+ left: 0;
169
+ width: 100%;
170
+ background-color: var(--bg-strong);
171
+ border-radius: 3px;
172
+ pointer-events: none;
173
+ }
174
+
175
+ .volume-thumb {
176
+ position: absolute;
177
+ left: 50%;
178
+ transform: translate(-50%, 50%);
179
+ width: 0.75em;
180
+ height: 0.75em;
181
+ border-radius: 50%;
182
+ background-color: var(--fg-normal);
183
+ pointer-events: none;
184
+ box-shadow: 0 0 5px #0001;
185
+ }
186
+
187
+ .volume-input {
188
+ appearance: slider-vertical;
189
+ position: absolute;
190
+ inset: 0;
191
+ width: 100%;
192
+ height: 100%;
193
+ margin: 0;
194
+ cursor: pointer;
195
+ writing-mode: vertical-lr;
196
+ direction: rtl;
197
+ opacity: 0;
198
+ }
199
+ }
200
+ </style>
@@ -0,0 +1,65 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Icon from './Icon.svelte';
4
+ import MediaControls from './MediaControls.svelte';
5
+ import { getMetadata, MediaState, type MediaProps } from './media.svelte.js';
6
+
7
+ interface Props extends MediaProps {
8
+ extraControls?: Snippet;
9
+ }
10
+
11
+ const { extraControls, ...rest }: Props = $props();
12
+ const { src } = rest;
13
+
14
+ const { metadata, pictureURL } = await getMetadata(rest);
15
+
16
+ const media = new MediaState();
17
+ </script>
18
+
19
+ <div class="Video" onkeydown={media.keydown}>
20
+ <video
21
+ {src}
22
+ bind:this={media.element}
23
+ bind:currentTime={media.currentTime}
24
+ bind:duration={media.duration}
25
+ bind:volume={media.volume}
26
+ bind:paused={media.paused}
27
+ bind:muted={media.muted}
28
+ bind:buffered={media.buffered}
29
+ bind:playbackRate={media.playbackRate}
30
+ bind:ended={media.ended}
31
+ onclick={media.click}
32
+ poster={pictureURL}
33
+ >
34
+ <track kind="captions" />
35
+ </video>
36
+ <MediaControls {media}>
37
+ <button class="reset icon-text" onclick={() => media.element?.requestFullscreen()}>
38
+ <Icon i="expand-wide" />
39
+ </button>
40
+ {#if extraControls}{@render extraControls()}{/if}
41
+ </MediaControls>
42
+ </div>
43
+
44
+ <style>
45
+ .Video {
46
+ display: flex;
47
+ flex-direction: column;
48
+ align-items: center;
49
+ justify-content: center;
50
+ width: 100%;
51
+ height: 100%;
52
+ gap: 1em;
53
+
54
+ :global(.MediaControls) {
55
+ width: 100%;
56
+ }
57
+ }
58
+
59
+ video {
60
+ max-width: 100%;
61
+ max-height: 100%;
62
+ min-height: 0;
63
+ object-fit: contain;
64
+ }
65
+ </style>
package/lib/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { default as AccessControlDialog } from './AccessControlDialog.svelte';
2
2
  export { default as AppMenu } from './AppMenu.svelte';
3
+ export { default as Audio } from './Audio.svelte';
3
4
  export { default as ColorPicker } from './ColorPicker.svelte';
4
5
  export { default as ClipboardCopy } from './ClipboardCopy.svelte';
5
6
  export { default as Discovery } from './Discovery.svelte';
@@ -9,6 +10,7 @@ export { default as Icon } from './Icon.svelte';
9
10
  export { default as LocationSelect } from './LocationSelect.svelte';
10
11
  export { default as Login } from './Login.svelte';
11
12
  export { default as Logout } from './Logout.svelte';
13
+ export { default as MediaControls } from './MediaControls.svelte';
12
14
  export { default as NumberBar } from './NumberBar.svelte';
13
15
  export { default as Popover } from './Popover.svelte';
14
16
  export { default as Register } from './Register.svelte';
@@ -19,6 +21,7 @@ export { default as URLText } from './URLText.svelte';
19
21
  export { default as UserCard } from './UserCard.svelte';
20
22
  export { default as UserPFP } from './UserPFP.svelte';
21
23
  export { default as UserMenu } from './UserMenu.svelte';
24
+ export { default as Video } from './Video.svelte';
22
25
  export { default as Version } from './Version.svelte';
23
26
  export { default as ZodForm } from './ZodForm.svelte';
24
27
  export { default as ZodInput } from './ZodInput.svelte';
@@ -0,0 +1,95 @@
1
+ import { parseBuffer, parseWebStream, selectCover } from 'music-metadata';
2
+ import type { IAudioMetadata, IPicture } from 'music-metadata';
3
+ import type { SvelteMediaTimeRange } from 'svelte/elements';
4
+
5
+ export interface MediaProps {
6
+ src: string;
7
+ metadataSource?: ReadableStream<Uint8Array> | Uint8Array;
8
+ size: number | bigint;
9
+ type: string;
10
+ name?: string;
11
+ }
12
+
13
+ export interface MediaMetadataResult {
14
+ metadata: IAudioMetadata;
15
+ picture: (IPicture & { data: Uint8Array<ArrayBuffer> }) | null;
16
+ pictureURL?: string;
17
+ }
18
+
19
+ export async function getMetadata(props: MediaProps): Promise<MediaMetadataResult> {
20
+ const metadataFileInfo = { size: Number(props.size), mimeType: props.type, url: props.src, path: props.name };
21
+
22
+ const metadata = await (ArrayBuffer.isView(props.metadataSource)
23
+ ? parseBuffer(props.metadataSource, metadataFileInfo)
24
+ : parseWebStream(props.metadataSource, metadataFileInfo));
25
+
26
+ // `picture.data`'s `source` is actually an `ArrayBufferLike`, we need it to be the more specific `ArrayBuffer`
27
+ const picture = selectCover(metadata.common.picture) satisfies IPicture | null as MediaMetadataResult['picture'];
28
+
29
+ if (props.metadataSource && !ArrayBuffer.isView(props.metadataSource)) await props.metadataSource.cancel();
30
+
31
+ return {
32
+ metadata,
33
+ picture,
34
+ pictureURL: picture ? URL.createObjectURL(new Blob([picture.data], { type: picture.format })) : undefined,
35
+ };
36
+ }
37
+
38
+ export class MediaState {
39
+ currentTime = $state<number>(0);
40
+ playbackRate = $state<number>();
41
+ paused = $state<boolean>();
42
+ volume = $state<number>(1);
43
+ muted = $state<boolean>();
44
+ duration = $state<number>(0);
45
+ buffered = $state<SvelteMediaTimeRange[]>([]);
46
+ seekable = $state<boolean>();
47
+ seeking = $state<boolean>();
48
+ ended = $state<boolean>();
49
+ element = $state<HTMLMediaElement>();
50
+
51
+ click = () => {
52
+ if (this.ended) {
53
+ this.currentTime = 0;
54
+ this.paused = false;
55
+ } else {
56
+ this.paused = !this.paused;
57
+ }
58
+ };
59
+
60
+ keydown = (e: KeyboardEvent) => {
61
+ switch (e.key) {
62
+ case 'ArrowLeft':
63
+ e.preventDefault();
64
+ this.currentTime = Math.max(0, this.currentTime - 10);
65
+ break;
66
+ case 'ArrowRight':
67
+ e.preventDefault();
68
+ this.currentTime = Math.min(this.duration, this.currentTime + 10);
69
+ break;
70
+ case 'ArrowUp':
71
+ this.volume = Math.min(1, this.volume + 0.1);
72
+ break;
73
+ case 'ArrowDown':
74
+ this.volume = Math.max(0, this.volume - 0.1);
75
+ break;
76
+ case 'F11':
77
+ e.preventDefault();
78
+ this.element?.requestFullscreen();
79
+ break;
80
+ case ' ':
81
+ this.click();
82
+ break;
83
+ case 'm':
84
+ this.muted = !this.muted;
85
+ break;
86
+ case 'p':
87
+ if (this.element && this.element instanceof HTMLVideoElement) {
88
+ this.element.requestPictureInPicture?.();
89
+ } else {
90
+ console.warn('Not a video element, can not use Picture-in-Picture');
91
+ }
92
+ break;
93
+ }
94
+ };
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.23.6",
3
+ "version": "0.24.0",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -45,7 +45,7 @@
45
45
  "build": "tsc"
46
46
  },
47
47
  "peerDependencies": {
48
- "@axium/core": ">=0.27.1",
48
+ "@axium/core": ">=0.28.3",
49
49
  "ioium": "^1.0.2",
50
50
  "semver": "^7.7.4",
51
51
  "svelte": "^5.36.0",
@@ -54,6 +54,7 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@simplewebauthn/browser": "^13.1.0",
57
- "commander": "^14.0.0"
57
+ "commander": "^14.0.0",
58
+ "music-metadata": "^11.12.3"
58
59
  }
59
60
  }
@@ -49,6 +49,7 @@
49
49
 
50
50
  .passkey {
51
51
  grid-template-columns: 1fr 1fr 1em 1em;
52
+ padding: 1em 0;
52
53
 
53
54
  p:first-child {
54
55
  display: grid;