@axium/client 0.36.4 → 0.36.6
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/lib/MediaControls.svelte +10 -3
- package/lib/Video.svelte +98 -12
- package/lib/ZodInput.svelte +3 -3
- package/lib/reactive/media.svelte.ts +70 -8
- package/package.json +1 -1
package/lib/MediaControls.svelte
CHANGED
|
@@ -6,17 +6,19 @@
|
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
8
8
|
media: MediaState;
|
|
9
|
+
/** Whether these are layered over the media rather than placed below it */
|
|
10
|
+
overlay?: boolean;
|
|
9
11
|
children?: Snippet;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
const { media, children }: Props = $props();
|
|
14
|
+
const { media, overlay, children }: Props = $props();
|
|
13
15
|
|
|
14
16
|
const duration = $derived(media.knownDuration);
|
|
15
17
|
</script>
|
|
16
18
|
|
|
17
|
-
<div class=
|
|
19
|
+
<div class={['MediaControls', overlay && 'overlay']}>
|
|
18
20
|
<button class="reset icon-text" onclick={media.click}>
|
|
19
|
-
<Icon i={media.
|
|
21
|
+
<Icon i={media.playIcon} />
|
|
20
22
|
</button>
|
|
21
23
|
<div class={['timeline', !duration && 'unknown-duration']}>
|
|
22
24
|
<div class="timeline-track">
|
|
@@ -70,6 +72,11 @@
|
|
|
70
72
|
border-radius: 0.75em;
|
|
71
73
|
background-color: var(--bg-menu);
|
|
72
74
|
|
|
75
|
+
&.overlay {
|
|
76
|
+
background-color: hsl(from var(--bg-menu) h s l / 80%);
|
|
77
|
+
box-shadow: 0 4px 12px #0004;
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
> :not(.timeline) {
|
|
74
81
|
flex: 0 0 auto;
|
|
75
82
|
}
|
package/lib/Video.svelte
CHANGED
|
@@ -6,14 +6,43 @@
|
|
|
6
6
|
|
|
7
7
|
interface Props extends MediaProps {
|
|
8
8
|
extraControls?: Snippet;
|
|
9
|
+
media?: MediaState;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
const { extraControls, ...rest }: Props = $props();
|
|
12
|
+
const { extraControls, media = new MediaState(), ...rest }: Props = $props();
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
+
const overlay = $derived(media.fullscreen || media.touch);
|
|
15
|
+
|
|
16
|
+
$effect(media.showControls);
|
|
17
|
+
|
|
18
|
+
const onSurface = (e: MouseEvent) => e.target === e.currentTarget || e.target === media.element;
|
|
19
|
+
|
|
20
|
+
function surface(e: MouseEvent) {
|
|
21
|
+
if (e.detail > 1 || !onSurface(e)) return;
|
|
22
|
+
if (media.touch) media.toggleControls();
|
|
23
|
+
else media.click();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Double tapping either side of the centered play button skips */
|
|
27
|
+
function skip(e: MouseEvent & { currentTarget: HTMLElement }) {
|
|
28
|
+
if (!media.touch || !onSurface(e)) return;
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
const { left, width } = e.currentTarget.getBoundingClientRect();
|
|
31
|
+
media.skip(e.clientX < left + width / 2 ? -media.skipSeconds : media.skipSeconds);
|
|
32
|
+
media.showControls();
|
|
33
|
+
}
|
|
14
34
|
</script>
|
|
15
35
|
|
|
16
|
-
<
|
|
36
|
+
<svelte:document onfullscreenchange={media.updateFullscreen} />
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
class={['Video', overlay && 'overlay', !media.controlsVisible && 'hide-controls']}
|
|
40
|
+
onkeydown={media.keydown}
|
|
41
|
+
onclick={surface}
|
|
42
|
+
ondblclick={skip}
|
|
43
|
+
onpointermove={e => e.pointerType == 'mouse' && media.showControls()}
|
|
44
|
+
bind:this={media.container}
|
|
45
|
+
>
|
|
17
46
|
<video
|
|
18
47
|
src={rest.src}
|
|
19
48
|
preload="metadata"
|
|
@@ -26,13 +55,17 @@
|
|
|
26
55
|
bind:buffered={media.buffered}
|
|
27
56
|
bind:playbackRate={media.playbackRate}
|
|
28
57
|
bind:ended={media.ended}
|
|
29
|
-
onclick={media.click}
|
|
30
58
|
>
|
|
31
59
|
<track kind="captions" />
|
|
32
60
|
</video>
|
|
33
|
-
|
|
34
|
-
<button class="reset
|
|
35
|
-
<Icon i=
|
|
61
|
+
{#if media.touch}
|
|
62
|
+
<button class="reset play-toggle" onclick={media.click}>
|
|
63
|
+
<Icon i={media.playIcon} />
|
|
64
|
+
</button>
|
|
65
|
+
{/if}
|
|
66
|
+
<MediaControls {media} {overlay}>
|
|
67
|
+
<button class="reset icon-text" onclick={media.toggleFullscreen}>
|
|
68
|
+
<Icon i={media.fullscreen ? 'compress-wide' : 'expand-wide'} />
|
|
36
69
|
</button>
|
|
37
70
|
{#if extraControls}{@render extraControls()}{/if}
|
|
38
71
|
</MediaControls>
|
|
@@ -40,6 +73,7 @@
|
|
|
40
73
|
|
|
41
74
|
<style>
|
|
42
75
|
.Video {
|
|
76
|
+
position: relative;
|
|
43
77
|
display: flex;
|
|
44
78
|
flex-direction: column;
|
|
45
79
|
align-items: center;
|
|
@@ -47,16 +81,68 @@
|
|
|
47
81
|
width: 100%;
|
|
48
82
|
height: 100%;
|
|
49
83
|
gap: 1em;
|
|
84
|
+
touch-action: manipulation;
|
|
50
85
|
|
|
51
86
|
:global(.MediaControls) {
|
|
52
87
|
width: 100%;
|
|
53
88
|
}
|
|
89
|
+
|
|
90
|
+
> video {
|
|
91
|
+
max-width: 100%;
|
|
92
|
+
max-height: 100%;
|
|
93
|
+
min-height: 0;
|
|
94
|
+
object-fit: contain;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
&:fullscreen {
|
|
98
|
+
background-color: #000;
|
|
99
|
+
|
|
100
|
+
> video {
|
|
101
|
+
width: 100%;
|
|
102
|
+
height: 100%;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
&.hide-controls {
|
|
106
|
+
cursor: none;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&.overlay {
|
|
111
|
+
gap: 0;
|
|
112
|
+
|
|
113
|
+
:global(.MediaControls) {
|
|
114
|
+
position: absolute;
|
|
115
|
+
right: 1em;
|
|
116
|
+
bottom: 1em;
|
|
117
|
+
left: 1em;
|
|
118
|
+
width: auto;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
:global(.MediaControls),
|
|
122
|
+
.play-toggle {
|
|
123
|
+
transition: opacity 150ms ease;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Keep them reachable by keyboard even once the countdown has run out */
|
|
127
|
+
&.hide-controls :global(.MediaControls:not(:focus-within)),
|
|
128
|
+
&.hide-controls .play-toggle {
|
|
129
|
+
opacity: 0;
|
|
130
|
+
pointer-events: none;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
54
133
|
}
|
|
55
134
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
135
|
+
.play-toggle {
|
|
136
|
+
--size: 2em;
|
|
137
|
+
position: absolute;
|
|
138
|
+
top: 50%;
|
|
139
|
+
left: 50%;
|
|
140
|
+
transform: translate(-50%, -50%);
|
|
141
|
+
padding: 0.75em;
|
|
142
|
+
border-radius: 50%;
|
|
143
|
+
background-color: hsl(from var(--bg-menu) h s l / 80%);
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
61
147
|
}
|
|
62
148
|
</style>
|
package/lib/ZodInput.svelte
CHANGED
|
@@ -106,9 +106,9 @@
|
|
|
106
106
|
|
|
107
107
|
const oninput = onchange;
|
|
108
108
|
|
|
109
|
-
/** Localize text for a given field in the current schema */
|
|
110
|
-
function subText(name: string, replacements?: ReplacementOptions & Record<string, any>) {
|
|
111
|
-
if (!localeInfo?.prefix) return;
|
|
109
|
+
/** Localize text for a given field in the current schema. */
|
|
110
|
+
function subText(name: string, replacements?: ReplacementOptions & Record<string, any>): string | undefined {
|
|
111
|
+
if (!localeInfo?.prefix) return replacements?.$default;
|
|
112
112
|
return text(`${localeInfo.prefix}.${name}`, replacements);
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { parseBuffer, parseWebStream, selectCover } from 'music-metadata';
|
|
2
1
|
import type { IAudioMetadata, IPicture } from 'music-metadata';
|
|
2
|
+
import { parseBuffer, parseWebStream, selectCover } from 'music-metadata';
|
|
3
3
|
import type { SvelteMediaTimeRange } from 'svelte/elements';
|
|
4
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
5
|
+
|
|
6
|
+
// Touch-primary devices have no hover, so the controls have to be tapped in and out of view
|
|
7
|
+
const coarsePointer = new MediaQuery('pointer: coarse', false);
|
|
4
8
|
|
|
5
9
|
type MetadataSource = ReadableStream<Uint8Array> | Uint8Array;
|
|
6
10
|
|
|
@@ -55,6 +59,18 @@ export class MediaState {
|
|
|
55
59
|
seeking = $state<boolean>();
|
|
56
60
|
ended = $state<boolean>();
|
|
57
61
|
element = $state<HTMLMediaElement>();
|
|
62
|
+
container = $state<HTMLElement>();
|
|
63
|
+
fullscreen = $state<boolean>(false);
|
|
64
|
+
/** Whether the controls are shown. Only has an effect while they overlay the media. */
|
|
65
|
+
controlsVisible = $state<boolean>(true);
|
|
66
|
+
|
|
67
|
+
#hideTimer?: ReturnType<typeof setTimeout>;
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
public hideDelay: number = 5000,
|
|
71
|
+
/** How far the arrow keys and double taps skip */
|
|
72
|
+
public skipSeconds: number = 10
|
|
73
|
+
) {}
|
|
58
74
|
|
|
59
75
|
click = () => {
|
|
60
76
|
if (this.ended) {
|
|
@@ -70,19 +86,61 @@ export class MediaState {
|
|
|
70
86
|
return Number.isFinite(this.duration) && this.duration > 0 ? this.duration : 0;
|
|
71
87
|
}
|
|
72
88
|
|
|
89
|
+
get playIcon(): string {
|
|
90
|
+
return this.ended ? 'arrow-rotate-right' : this.paused ? 'play' : 'pause';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get fullscreenTarget(): HTMLElement | null {
|
|
94
|
+
if (this.container) return this.container;
|
|
95
|
+
return this.element instanceof HTMLVideoElement ? this.element : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether tapping the media toggles the controls rather than playback */
|
|
99
|
+
get touch(): boolean {
|
|
100
|
+
return coarsePointer.current;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
showControls = () => {
|
|
104
|
+
this.controlsVisible = true;
|
|
105
|
+
clearTimeout(this.#hideTimer);
|
|
106
|
+
if (this.paused || !this.fullscreen) return;
|
|
107
|
+
this.#hideTimer = setTimeout(() => (this.controlsVisible = false), this.hideDelay);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
hideControls = () => {
|
|
111
|
+
clearTimeout(this.#hideTimer);
|
|
112
|
+
this.controlsVisible = false;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
toggleControls = () => {
|
|
116
|
+
if (this.controlsVisible) this.hideControls();
|
|
117
|
+
else this.showControls();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
skip(by: number) {
|
|
121
|
+
if (!this.knownDuration) return;
|
|
122
|
+
this.seek(this.currentTime + by);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
toggleFullscreen = () => {
|
|
126
|
+
if (globalThis.document?.fullscreenElement) void globalThis.document.exitFullscreen();
|
|
127
|
+
else void this.fullscreenTarget?.requestFullscreen();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Keeps `fullscreen` in sync, use with `<svelte:document onfullscreenchange={media.updateFullscreen} />` */
|
|
131
|
+
updateFullscreen = () => {
|
|
132
|
+
this.fullscreen = !!this.fullscreenTarget && globalThis.document?.fullscreenElement === this.fullscreenTarget;
|
|
133
|
+
};
|
|
134
|
+
|
|
73
135
|
keydown = (e: KeyboardEvent) => {
|
|
74
136
|
switch (e.key) {
|
|
75
137
|
case 'ArrowLeft':
|
|
76
138
|
e.preventDefault();
|
|
77
|
-
|
|
78
|
-
this.currentTime = Math.max(0, this.currentTime - 10);
|
|
79
|
-
this.updateAttached();
|
|
139
|
+
this.skip(-this.skipSeconds);
|
|
80
140
|
break;
|
|
81
141
|
case 'ArrowRight':
|
|
82
142
|
e.preventDefault();
|
|
83
|
-
|
|
84
|
-
this.currentTime = Math.min(this.knownDuration, this.currentTime + 10);
|
|
85
|
-
this.updateAttached();
|
|
143
|
+
this.skip(this.skipSeconds);
|
|
86
144
|
break;
|
|
87
145
|
case 'ArrowUp':
|
|
88
146
|
this.volume = Math.min(1, this.volume + 0.1);
|
|
@@ -91,8 +149,9 @@ export class MediaState {
|
|
|
91
149
|
this.volume = Math.max(0, this.volume - 0.1);
|
|
92
150
|
break;
|
|
93
151
|
case 'F11':
|
|
152
|
+
case 'f':
|
|
94
153
|
e.preventDefault();
|
|
95
|
-
this.
|
|
154
|
+
this.toggleFullscreen();
|
|
96
155
|
break;
|
|
97
156
|
case ' ':
|
|
98
157
|
this.click();
|
|
@@ -107,6 +166,9 @@ export class MediaState {
|
|
|
107
166
|
console.warn('Not a video element, can not use Picture-in-Picture');
|
|
108
167
|
}
|
|
109
168
|
break;
|
|
169
|
+
case 'c':
|
|
170
|
+
if (this.fullscreen) this.toggleControls();
|
|
171
|
+
break;
|
|
110
172
|
}
|
|
111
173
|
};
|
|
112
174
|
|