@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.
@@ -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
@@ -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 media = new MediaState();
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
- <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
+ >
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
- <MediaControls {media}>
34
- <button class="reset icon-text" onclick={() => media.element?.requestFullscreen()}>
35
- <Icon i="expand-wide" />
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
- video {
57
- max-width: 100%;
58
- max-height: 100%;
59
- min-height: 0;
60
- object-fit: contain;
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>
@@ -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
- if (!this.knownDuration) break;
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
- if (!this.knownDuration) break;
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.element?.requestFullscreen();
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.36.4",
3
+ "version": "0.36.6",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",