@a4ui/core 0.33.0 → 0.35.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/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Custom video player over the native <video> element — no media library.
|
|
2
|
+
// Controls (play/pause, scrub, volume, fullscreen, PiP) are hand-rolled on top
|
|
3
|
+
// of the video's own event listeners (timeupdate/play/pause/volumechange),
|
|
4
|
+
// wired up in onMount and torn down in onCleanup. The control bar auto-hides
|
|
5
|
+
// during playback and reappears on pointer movement or keyboard focus.
|
|
6
|
+
import { Maximize, Pause, PictureInPicture2, Play, Volume2, VolumeX } from 'lucide-solid'
|
|
7
|
+
import type { JSX } from 'solid-js'
|
|
8
|
+
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
|
|
12
|
+
export interface VideoPlayerShellProps {
|
|
13
|
+
src: string
|
|
14
|
+
poster?: string
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const AUTO_HIDE_MS = 2200
|
|
19
|
+
const SEEK_STEP_SECONDS = 5
|
|
20
|
+
|
|
21
|
+
function formatTime(seconds: number): string {
|
|
22
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
|
|
23
|
+
const total = Math.floor(seconds)
|
|
24
|
+
const m = Math.floor(total / 60)
|
|
25
|
+
const s = total % 60
|
|
26
|
+
return `${m}:${s.toString().padStart(2, '0')}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Native `<video>` wrapped in a glass shell with fully custom controls: a big
|
|
31
|
+
* center play/pause, a bottom bar (play/pause, elapsed/duration, a scrub
|
|
32
|
+
* range, mute toggle, fullscreen, Picture-in-Picture), and a keyboard map
|
|
33
|
+
* (Space = play/pause, ArrowLeft/Right = seek ±5s). Controls auto-hide while
|
|
34
|
+
* playing and reappear on pointer movement.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* <VideoPlayerShell src="/media/demo.mp4" poster="/media/demo-poster.jpg" />
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function VideoPlayerShell(props: VideoPlayerShellProps): JSX.Element {
|
|
42
|
+
let containerRef: HTMLDivElement | undefined
|
|
43
|
+
let videoRef: HTMLVideoElement | undefined
|
|
44
|
+
|
|
45
|
+
const [playing, setPlaying] = createSignal(false)
|
|
46
|
+
const [currentTime, setCurrentTime] = createSignal(0)
|
|
47
|
+
const [duration, setDuration] = createSignal(0)
|
|
48
|
+
const [muted, setMuted] = createSignal(false)
|
|
49
|
+
const [volume, setVolume] = createSignal(1)
|
|
50
|
+
const [fullscreen, setFullscreen] = createSignal(false)
|
|
51
|
+
const [pipSupported, setPipSupported] = createSignal(false)
|
|
52
|
+
const [controlsVisible, setControlsVisible] = createSignal(true)
|
|
53
|
+
|
|
54
|
+
let hideTimer: ReturnType<typeof setTimeout> | undefined
|
|
55
|
+
|
|
56
|
+
const clearHideTimer = (): void => {
|
|
57
|
+
if (hideTimer !== undefined) clearTimeout(hideTimer)
|
|
58
|
+
hideTimer = undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const scheduleHide = (): void => {
|
|
62
|
+
clearHideTimer()
|
|
63
|
+
if (!playing()) return
|
|
64
|
+
hideTimer = setTimeout(() => setControlsVisible(false), AUTO_HIDE_MS)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const revealControls = (): void => {
|
|
68
|
+
setControlsVisible(true)
|
|
69
|
+
scheduleHide()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const togglePlay = (): void => {
|
|
73
|
+
const video = videoRef
|
|
74
|
+
if (!video) return
|
|
75
|
+
if (video.paused) void video.play()
|
|
76
|
+
else video.pause()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const seekBy = (deltaSeconds: number): void => {
|
|
80
|
+
const video = videoRef
|
|
81
|
+
if (!video) return
|
|
82
|
+
video.currentTime = Math.min(Math.max(video.currentTime + deltaSeconds, 0), video.duration || 0)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleScrub = (event: Event & { currentTarget: HTMLInputElement }): void => {
|
|
86
|
+
const video = videoRef
|
|
87
|
+
if (!video) return
|
|
88
|
+
video.currentTime = Number(event.currentTarget.value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const toggleMute = (): void => {
|
|
92
|
+
const video = videoRef
|
|
93
|
+
if (!video) return
|
|
94
|
+
video.muted = !video.muted
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const handleVolume = (event: Event & { currentTarget: HTMLInputElement }): void => {
|
|
98
|
+
const video = videoRef
|
|
99
|
+
if (!video) return
|
|
100
|
+
const next = Number(event.currentTarget.value)
|
|
101
|
+
video.volume = next
|
|
102
|
+
video.muted = next === 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const toggleFullscreen = (): void => {
|
|
106
|
+
const container = containerRef
|
|
107
|
+
if (!container) return
|
|
108
|
+
if (document.fullscreenElement) void document.exitFullscreen()
|
|
109
|
+
else void container.requestFullscreen()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const togglePip = (): void => {
|
|
113
|
+
const video = videoRef
|
|
114
|
+
if (!video || !pipSupported()) return
|
|
115
|
+
if (document.pictureInPictureElement) void document.exitPictureInPicture()
|
|
116
|
+
else void video.requestPictureInPicture()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
120
|
+
if (event.code === 'Space') {
|
|
121
|
+
event.preventDefault()
|
|
122
|
+
togglePlay()
|
|
123
|
+
} else if (event.code === 'ArrowLeft') {
|
|
124
|
+
event.preventDefault()
|
|
125
|
+
seekBy(-SEEK_STEP_SECONDS)
|
|
126
|
+
} else if (event.code === 'ArrowRight') {
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
seekBy(SEEK_STEP_SECONDS)
|
|
129
|
+
}
|
|
130
|
+
revealControls()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onMount(() => {
|
|
134
|
+
const video = videoRef
|
|
135
|
+
if (!video) return
|
|
136
|
+
|
|
137
|
+
setPipSupported(
|
|
138
|
+
typeof document !== 'undefined' &&
|
|
139
|
+
'pictureInPictureEnabled' in document &&
|
|
140
|
+
!video.disablePictureInPicture,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const onPlay = (): void => {
|
|
144
|
+
setPlaying(true)
|
|
145
|
+
scheduleHide()
|
|
146
|
+
}
|
|
147
|
+
const onPause = (): void => {
|
|
148
|
+
setPlaying(false)
|
|
149
|
+
clearHideTimer()
|
|
150
|
+
setControlsVisible(true)
|
|
151
|
+
}
|
|
152
|
+
const onTimeUpdate = (): void => {
|
|
153
|
+
setCurrentTime(video.currentTime)
|
|
154
|
+
}
|
|
155
|
+
const onLoadedMetadata = (): void => {
|
|
156
|
+
setDuration(video.duration || 0)
|
|
157
|
+
}
|
|
158
|
+
const onVolumeChange = (): void => {
|
|
159
|
+
setMuted(video.muted)
|
|
160
|
+
setVolume(video.volume)
|
|
161
|
+
}
|
|
162
|
+
const onFullscreenChange = (): void => {
|
|
163
|
+
setFullscreen(document.fullscreenElement === containerRef)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
video.addEventListener('play', onPlay)
|
|
167
|
+
video.addEventListener('pause', onPause)
|
|
168
|
+
video.addEventListener('timeupdate', onTimeUpdate)
|
|
169
|
+
video.addEventListener('loadedmetadata', onLoadedMetadata)
|
|
170
|
+
video.addEventListener('volumechange', onVolumeChange)
|
|
171
|
+
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
172
|
+
|
|
173
|
+
onCleanup(() => {
|
|
174
|
+
video.removeEventListener('play', onPlay)
|
|
175
|
+
video.removeEventListener('pause', onPause)
|
|
176
|
+
video.removeEventListener('timeupdate', onTimeUpdate)
|
|
177
|
+
video.removeEventListener('loadedmetadata', onLoadedMetadata)
|
|
178
|
+
video.removeEventListener('volumechange', onVolumeChange)
|
|
179
|
+
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
180
|
+
clearHideTimer()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
ref={containerRef}
|
|
187
|
+
class={cn('card group relative aspect-video w-full overflow-hidden bg-black outline-none', props.class)}
|
|
188
|
+
tabIndex={0}
|
|
189
|
+
onKeyDown={handleKeyDown}
|
|
190
|
+
onPointerMove={revealControls}
|
|
191
|
+
onMouseLeave={() => playing() && setControlsVisible(false)}
|
|
192
|
+
>
|
|
193
|
+
<video
|
|
194
|
+
ref={videoRef}
|
|
195
|
+
src={props.src}
|
|
196
|
+
poster={props.poster}
|
|
197
|
+
class="h-full w-full object-contain"
|
|
198
|
+
onClick={togglePlay}
|
|
199
|
+
/>
|
|
200
|
+
|
|
201
|
+
{/* Big center play/pause */}
|
|
202
|
+
<Show when={controlsVisible() || !playing()}>
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
aria-label={playing() ? 'Pause' : 'Play'}
|
|
206
|
+
onClick={togglePlay}
|
|
207
|
+
class="absolute inset-0 flex items-center justify-center transition-opacity duration-200"
|
|
208
|
+
>
|
|
209
|
+
<Show when={!playing()}>
|
|
210
|
+
<span class="flex h-16 w-16 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm transition-transform duration-150 hover:scale-105">
|
|
211
|
+
<Play class="h-7 w-7 translate-x-0.5" fill="currentColor" />
|
|
212
|
+
</span>
|
|
213
|
+
</Show>
|
|
214
|
+
</button>
|
|
215
|
+
</Show>
|
|
216
|
+
|
|
217
|
+
{/* Bottom control bar */}
|
|
218
|
+
<div
|
|
219
|
+
class={cn(
|
|
220
|
+
'absolute inset-x-0 bottom-0 flex flex-col gap-1.5 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-2 pt-6 text-white transition-opacity duration-200',
|
|
221
|
+
controlsVisible() ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
<input
|
|
225
|
+
type="range"
|
|
226
|
+
aria-label="Seek"
|
|
227
|
+
min={0}
|
|
228
|
+
max={duration() || 0}
|
|
229
|
+
step={0.1}
|
|
230
|
+
value={currentTime()}
|
|
231
|
+
onInput={handleScrub}
|
|
232
|
+
class="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-white/25 accent-primary"
|
|
233
|
+
/>
|
|
234
|
+
<div class="flex items-center gap-2">
|
|
235
|
+
<button type="button" aria-label={playing() ? 'Pause' : 'Play'} onClick={togglePlay} class="p-1">
|
|
236
|
+
<Show when={playing()} fallback={<Play class="h-4 w-4" fill="currentColor" />}>
|
|
237
|
+
<Pause class="h-4 w-4" fill="currentColor" />
|
|
238
|
+
</Show>
|
|
239
|
+
</button>
|
|
240
|
+
|
|
241
|
+
<span class="text-xs tabular-nums text-white/85">
|
|
242
|
+
{formatTime(currentTime())} / {formatTime(duration())}
|
|
243
|
+
</span>
|
|
244
|
+
|
|
245
|
+
<div class="ml-1 flex items-center gap-1">
|
|
246
|
+
<button type="button" aria-label={muted() ? 'Unmute' : 'Mute'} onClick={toggleMute} class="p-1">
|
|
247
|
+
<Show when={!muted() && volume() > 0} fallback={<VolumeX class="h-4 w-4" />}>
|
|
248
|
+
<Volume2 class="h-4 w-4" />
|
|
249
|
+
</Show>
|
|
250
|
+
</button>
|
|
251
|
+
<input
|
|
252
|
+
type="range"
|
|
253
|
+
aria-label="Volume"
|
|
254
|
+
min={0}
|
|
255
|
+
max={1}
|
|
256
|
+
step={0.05}
|
|
257
|
+
value={muted() ? 0 : volume()}
|
|
258
|
+
onInput={handleVolume}
|
|
259
|
+
class="h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/25 accent-primary"
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="ml-auto flex items-center gap-2">
|
|
264
|
+
<Show when={pipSupported()}>
|
|
265
|
+
<button type="button" aria-label="Picture in picture" onClick={togglePip} class="p-1">
|
|
266
|
+
<PictureInPicture2 class="h-4 w-4" />
|
|
267
|
+
</button>
|
|
268
|
+
</Show>
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
aria-label={fullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
272
|
+
onClick={toggleFullscreen}
|
|
273
|
+
class="p-1"
|
|
274
|
+
>
|
|
275
|
+
<Maximize class="h-4 w-4" />
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)
|
|
282
|
+
}
|