@axium/client 0.36.5 → 0.36.7

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/Audio.svelte CHANGED
@@ -10,7 +10,7 @@
10
10
  extraControls?: Snippet;
11
11
  }
12
12
 
13
- const { cover: showCover, extraControls, ...rest }: Props = $props();
13
+ const { cover: showCover, autoplay, extraControls, ...rest }: Props = $props();
14
14
 
15
15
  const id = $props.id();
16
16
 
@@ -68,6 +68,7 @@
68
68
  bind:buffered={media.buffered}
69
69
  bind:playbackRate={media.playbackRate}
70
70
  bind:ended={media.ended}
71
+ {autoplay}
71
72
  ></audio>
72
73
  <MediaControls {media}>
73
74
  {#if extraControls}{@render extraControls()}{/if}
@@ -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="MediaControls">
19
+ <div class={['MediaControls', overlay && 'overlay']}>
18
20
  <button class="reset icon-text" onclick={media.click}>
19
- <Icon i={media.ended ? 'arrow-rotate-right' : media.paused ? 'play' : 'pause'} />
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
@@ -9,10 +9,40 @@
9
9
  media?: MediaState;
10
10
  }
11
11
 
12
- const { extraControls, media = new MediaState(), ...rest }: Props = $props();
12
+ const { extraControls, autoplay, media = new MediaState(), ...rest }: Props = $props();
13
+
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
+ }
13
34
  </script>
14
35
 
15
- <div class="Video" onkeydown={media.keydown}>
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
+ >
16
46
  <video
17
47
  src={rest.src}
18
48
  preload="metadata"
@@ -25,13 +55,18 @@
25
55
  bind:buffered={media.buffered}
26
56
  bind:playbackRate={media.playbackRate}
27
57
  bind:ended={media.ended}
28
- onclick={media.click}
58
+ {autoplay}
29
59
  >
30
60
  <track kind="captions" />
31
61
  </video>
32
- <MediaControls {media}>
33
- <button class="reset icon-text" onclick={() => media.element?.requestFullscreen()}>
34
- <Icon i="expand-wide" />
62
+ {#if media.touch}
63
+ <button class="reset play-toggle" onclick={media.click}>
64
+ <Icon i={media.playIcon} />
65
+ </button>
66
+ {/if}
67
+ <MediaControls {media} {overlay}>
68
+ <button class="reset icon-text" onclick={media.toggleFullscreen}>
69
+ <Icon i={media.fullscreen ? 'compress-wide' : 'expand-wide'} />
35
70
  </button>
36
71
  {#if extraControls}{@render extraControls()}{/if}
37
72
  </MediaControls>
@@ -39,6 +74,7 @@
39
74
 
40
75
  <style>
41
76
  .Video {
77
+ position: relative;
42
78
  display: flex;
43
79
  flex-direction: column;
44
80
  align-items: center;
@@ -46,16 +82,68 @@
46
82
  width: 100%;
47
83
  height: 100%;
48
84
  gap: 1em;
85
+ touch-action: manipulation;
49
86
 
50
87
  :global(.MediaControls) {
51
88
  width: 100%;
52
89
  }
90
+
91
+ > video {
92
+ max-width: 100%;
93
+ max-height: 100%;
94
+ min-height: 0;
95
+ object-fit: contain;
96
+ }
97
+
98
+ &:fullscreen {
99
+ background-color: #000;
100
+
101
+ > video {
102
+ width: 100%;
103
+ height: 100%;
104
+ }
105
+
106
+ &.hide-controls {
107
+ cursor: none;
108
+ }
109
+ }
110
+
111
+ &.overlay {
112
+ gap: 0;
113
+
114
+ :global(.MediaControls) {
115
+ position: absolute;
116
+ right: 1em;
117
+ bottom: 1em;
118
+ left: 1em;
119
+ width: auto;
120
+ }
121
+
122
+ :global(.MediaControls),
123
+ .play-toggle {
124
+ transition: opacity 150ms ease;
125
+ }
126
+
127
+ /* Keep them reachable by keyboard even once the countdown has run out */
128
+ &.hide-controls :global(.MediaControls:not(:focus-within)),
129
+ &.hide-controls .play-toggle {
130
+ opacity: 0;
131
+ pointer-events: none;
132
+ }
133
+ }
53
134
  }
54
135
 
55
- video {
56
- max-width: 100%;
57
- max-height: 100%;
58
- min-height: 0;
59
- object-fit: contain;
136
+ .play-toggle {
137
+ --size: 2em;
138
+ position: absolute;
139
+ top: 50%;
140
+ left: 50%;
141
+ transform: translate(-50%, -50%);
142
+ padding: 0.75em;
143
+ border-radius: 50%;
144
+ background-color: hsl(from var(--bg-menu) h s l / 80%);
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
60
148
  }
61
149
  </style>
@@ -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
 
@@ -10,6 +14,7 @@ export interface MediaProps {
10
14
  size: number | bigint;
11
15
  type: string;
12
16
  name?: string;
17
+ autoplay?: boolean;
13
18
  }
14
19
 
15
20
  export type MediaPicture = (IPicture & { data: Uint8Array<ArrayBuffer> }) | null;
@@ -55,6 +60,18 @@ export class MediaState {
55
60
  seeking = $state<boolean>();
56
61
  ended = $state<boolean>();
57
62
  element = $state<HTMLMediaElement>();
63
+ container = $state<HTMLElement>();
64
+ fullscreen = $state<boolean>(false);
65
+ /** Whether the controls are shown. Only has an effect while they overlay the media. */
66
+ controlsVisible = $state<boolean>(true);
67
+
68
+ #hideTimer?: ReturnType<typeof setTimeout>;
69
+
70
+ constructor(
71
+ public hideDelay: number = 5000,
72
+ /** How far the arrow keys and double taps skip */
73
+ public skipSeconds: number = 10
74
+ ) {}
58
75
 
59
76
  click = () => {
60
77
  if (this.ended) {
@@ -70,19 +87,61 @@ export class MediaState {
70
87
  return Number.isFinite(this.duration) && this.duration > 0 ? this.duration : 0;
71
88
  }
72
89
 
90
+ get playIcon(): string {
91
+ return this.ended ? 'arrow-rotate-right' : this.paused ? 'play' : 'pause';
92
+ }
93
+
94
+ get fullscreenTarget(): HTMLElement | null {
95
+ if (this.container) return this.container;
96
+ return this.element instanceof HTMLVideoElement ? this.element : null;
97
+ }
98
+
99
+ /** Whether tapping the media toggles the controls rather than playback */
100
+ get touch(): boolean {
101
+ return coarsePointer.current;
102
+ }
103
+
104
+ showControls = () => {
105
+ this.controlsVisible = true;
106
+ clearTimeout(this.#hideTimer);
107
+ if (this.paused || !this.fullscreen) return;
108
+ this.#hideTimer = setTimeout(() => (this.controlsVisible = false), this.hideDelay);
109
+ };
110
+
111
+ hideControls = () => {
112
+ clearTimeout(this.#hideTimer);
113
+ this.controlsVisible = false;
114
+ };
115
+
116
+ toggleControls = () => {
117
+ if (this.controlsVisible) this.hideControls();
118
+ else this.showControls();
119
+ };
120
+
121
+ skip(by: number) {
122
+ if (!this.knownDuration) return;
123
+ this.seek(this.currentTime + by);
124
+ }
125
+
126
+ toggleFullscreen = () => {
127
+ if (globalThis.document?.fullscreenElement) void globalThis.document.exitFullscreen();
128
+ else void this.fullscreenTarget?.requestFullscreen();
129
+ };
130
+
131
+ /** Keeps `fullscreen` in sync, use with `<svelte:document onfullscreenchange={media.updateFullscreen} />` */
132
+ updateFullscreen = () => {
133
+ this.fullscreen = !!this.fullscreenTarget && globalThis.document?.fullscreenElement === this.fullscreenTarget;
134
+ };
135
+
73
136
  keydown = (e: KeyboardEvent) => {
74
137
  switch (e.key) {
75
138
  case 'ArrowLeft':
76
139
  e.preventDefault();
77
- if (!this.knownDuration) break;
78
- this.currentTime = Math.max(0, this.currentTime - 10);
79
- this.updateAttached();
140
+ this.skip(-this.skipSeconds);
80
141
  break;
81
142
  case 'ArrowRight':
82
143
  e.preventDefault();
83
- if (!this.knownDuration) break;
84
- this.currentTime = Math.min(this.knownDuration, this.currentTime + 10);
85
- this.updateAttached();
144
+ this.skip(this.skipSeconds);
86
145
  break;
87
146
  case 'ArrowUp':
88
147
  this.volume = Math.min(1, this.volume + 0.1);
@@ -91,8 +150,9 @@ export class MediaState {
91
150
  this.volume = Math.max(0, this.volume - 0.1);
92
151
  break;
93
152
  case 'F11':
153
+ case 'f':
94
154
  e.preventDefault();
95
- this.element?.requestFullscreen();
155
+ this.toggleFullscreen();
96
156
  break;
97
157
  case ' ':
98
158
  this.click();
@@ -107,6 +167,9 @@ export class MediaState {
107
167
  console.warn('Not a video element, can not use Picture-in-Picture');
108
168
  }
109
169
  break;
170
+ case 'c':
171
+ if (this.fullscreen) this.toggleControls();
172
+ break;
110
173
  }
111
174
  };
112
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.36.5",
3
+ "version": "0.36.7",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",