@a4ui/core 0.32.0 → 0.34.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/charts/BarList.d.ts +33 -0
- package/dist/charts/CategoryBar.d.ts +23 -0
- package/dist/charts/StatusTracker.d.ts +27 -0
- package/dist/charts/index.d.ts +3 -0
- package/dist/charts.js +446 -316
- package/dist/elements.css +278 -0
- package/dist/full.css +278 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +6423 -4195
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/Globe.d.ts +44 -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/ModelPicker.d.ts +38 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/ReasoningTrace.d.ts +28 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/Snippet.d.ts +19 -0
- package/dist/ui/SuggestionChips.d.ts +25 -0
- package/dist/ui/ToolCallTimeline.d.ts +34 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/UsageMeter.d.ts +26 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/dist/ui/WorldMap.d.ts +36 -0
- package/package.json +1 -1
- package/src/charts/BarList.tsx +83 -0
- package/src/charts/CategoryBar.tsx +95 -0
- package/src/charts/StatusTracker.tsx +81 -0
- package/src/charts/index.ts +3 -0
- package/src/index.ts +48 -1
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/Globe.tsx +317 -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/ModelPicker.tsx +119 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/ReasoningTrace.tsx +83 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/Snippet.tsx +64 -0
- package/src/ui/SuggestionChips.tsx +65 -0
- package/src/ui/ToolCallTimeline.tsx +137 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/UsageMeter.tsx +71 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
- package/src/ui/WorldMap.tsx +191 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Hierarchical data table: rows can nest via `children`, expand/collapse lives
|
|
2
|
+
// in the first column (chevron + depth indentation), all other columns render
|
|
3
|
+
// like a plain DataGrid column. Built on the Table primitives + Tree's toggle idiom.
|
|
4
|
+
import { ChevronRight } from 'lucide-solid'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from './Table'
|
|
10
|
+
|
|
11
|
+
/** A column definition for {@link TreeTable}. */
|
|
12
|
+
export interface TreeTableColumn<T> {
|
|
13
|
+
/** Row data property this column reads when `cell` is omitted. */
|
|
14
|
+
key: string
|
|
15
|
+
/** Column heading content. */
|
|
16
|
+
header: JSX.Element
|
|
17
|
+
/** Custom cell renderer; falls back to `String(row[key])` when omitted. */
|
|
18
|
+
cell?: (row: T) => JSX.Element
|
|
19
|
+
/** Right-align the column (numbers, totals). */
|
|
20
|
+
align?: 'left' | 'right'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A single node in a {@link TreeTable}; nodes may nest via `children`. */
|
|
24
|
+
export interface TreeTableRow<T> {
|
|
25
|
+
/** Unique identifier; used to track expanded state. */
|
|
26
|
+
id: string
|
|
27
|
+
/** Row payload passed to each column's `cell` renderer. */
|
|
28
|
+
data: T
|
|
29
|
+
/** Child rows revealed when this row is expanded. */
|
|
30
|
+
children?: TreeTableRow<T>[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TreeTableProps<T> {
|
|
34
|
+
columns: TreeTableColumn<T>[]
|
|
35
|
+
rows: TreeTableRow<T>[]
|
|
36
|
+
/** Expand every row with children on first render. Defaults to false. */
|
|
37
|
+
defaultExpanded?: boolean
|
|
38
|
+
class?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectIds<T>(rows: TreeTableRow<T>[], out: Set<string>): void {
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
if (row.children?.length) {
|
|
44
|
+
out.add(row.id)
|
|
45
|
+
collectIds(row.children, out)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hierarchical table: rows may carry `children`, revealed by clicking the
|
|
52
|
+
* chevron in the first column. Depth is shown via indentation; expanded state
|
|
53
|
+
* is tracked internally, seeded fully expanded when `defaultExpanded` is set.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <TreeTable
|
|
58
|
+
* defaultExpanded
|
|
59
|
+
* columns={[
|
|
60
|
+
* { key: 'name', header: 'Name' },
|
|
61
|
+
* { key: 'size', header: 'Size', align: 'right', cell: (row) => `${row.size} KB` },
|
|
62
|
+
* ]}
|
|
63
|
+
* rows={[
|
|
64
|
+
* {
|
|
65
|
+
* id: 'src',
|
|
66
|
+
* data: { name: 'src', size: 0 },
|
|
67
|
+
* children: [{ id: 'index', data: { name: 'index.ts', size: 2 } }],
|
|
68
|
+
* },
|
|
69
|
+
* ]}
|
|
70
|
+
* />
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function TreeTable<T>(props: TreeTableProps<T>): JSX.Element {
|
|
74
|
+
const [expanded, setExpanded] = createSignal<Set<string>>(
|
|
75
|
+
props.defaultExpanded ? seedExpanded(props.rows) : new Set<string>(),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
function seedExpanded(rows: TreeTableRow<T>[]): Set<string> {
|
|
79
|
+
const ids = new Set<string>()
|
|
80
|
+
collectIds(rows, ids)
|
|
81
|
+
return ids
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const toggle = (id: string) =>
|
|
85
|
+
setExpanded((prev) => {
|
|
86
|
+
const next = new Set(prev)
|
|
87
|
+
if (next.has(id)) next.delete(id)
|
|
88
|
+
else next.add(id)
|
|
89
|
+
return next
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const Rows = (rowsProps: { rows: TreeTableRow<T>[]; depth: number }): JSX.Element => (
|
|
93
|
+
<For each={rowsProps.rows}>
|
|
94
|
+
{(row) => {
|
|
95
|
+
const hasChildren = () => (row.children?.length ?? 0) > 0
|
|
96
|
+
const isExpanded = () => expanded().has(row.id)
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
<TableRow>
|
|
101
|
+
<For each={props.columns}>
|
|
102
|
+
{(col, colIndex) => (
|
|
103
|
+
<TableCell class={cn(col.align === 'right' && 'text-right tabular-nums')}>
|
|
104
|
+
<Show
|
|
105
|
+
when={colIndex() === 0}
|
|
106
|
+
fallback={
|
|
107
|
+
col.cell
|
|
108
|
+
? col.cell(row.data)
|
|
109
|
+
: String((row.data as Record<string, unknown>)[col.key] ?? '')
|
|
110
|
+
}
|
|
111
|
+
>
|
|
112
|
+
<div
|
|
113
|
+
class="flex items-center gap-1.5"
|
|
114
|
+
style={{ 'padding-left': `${rowsProps.depth * 1}rem` }}
|
|
115
|
+
>
|
|
116
|
+
<Show
|
|
117
|
+
when={hasChildren()}
|
|
118
|
+
fallback={<span class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
|
|
119
|
+
>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
|
|
123
|
+
aria-expanded={isExpanded()}
|
|
124
|
+
aria-label={isExpanded() ? 'Collapse row' : 'Expand row'}
|
|
125
|
+
onClick={() => toggle(row.id)}
|
|
126
|
+
>
|
|
127
|
+
<ChevronRight
|
|
128
|
+
class={cn(
|
|
129
|
+
'h-3.5 w-3.5 transition-transform duration-200',
|
|
130
|
+
isExpanded() && 'rotate-90',
|
|
131
|
+
)}
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
/>
|
|
134
|
+
</button>
|
|
135
|
+
</Show>
|
|
136
|
+
<span>
|
|
137
|
+
{col.cell
|
|
138
|
+
? col.cell(row.data)
|
|
139
|
+
: String((row.data as Record<string, unknown>)[col.key] ?? '')}
|
|
140
|
+
</span>
|
|
141
|
+
</div>
|
|
142
|
+
</Show>
|
|
143
|
+
</TableCell>
|
|
144
|
+
)}
|
|
145
|
+
</For>
|
|
146
|
+
</TableRow>
|
|
147
|
+
<Show when={hasChildren() && isExpanded()}>
|
|
148
|
+
<Rows rows={row.children ?? []} depth={rowsProps.depth + 1} />
|
|
149
|
+
</Show>
|
|
150
|
+
</>
|
|
151
|
+
)
|
|
152
|
+
}}
|
|
153
|
+
</For>
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<Table class={props.class}>
|
|
158
|
+
<TableHead>
|
|
159
|
+
<TableRow>
|
|
160
|
+
<For each={props.columns}>
|
|
161
|
+
{(col) => (
|
|
162
|
+
<TableHeadCell class={cn(col.align === 'right' && 'text-right')}>{col.header}</TableHeadCell>
|
|
163
|
+
)}
|
|
164
|
+
</For>
|
|
165
|
+
</TableRow>
|
|
166
|
+
</TableHead>
|
|
167
|
+
<TableBody>
|
|
168
|
+
<Rows rows={props.rows} depth={0} />
|
|
169
|
+
</TableBody>
|
|
170
|
+
</Table>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Labeled consumption bar (e.g. token/quota usage) on Kobalte's Meter
|
|
2
|
+
// primitive — same idiom as Meter.tsx/Progress.tsx, plus a warning-tone fill
|
|
3
|
+
// past a configurable threshold and a "N left" remaining line.
|
|
4
|
+
import { Meter as KMeter } from '@kobalte/core/meter'
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface UsageMeterProps {
|
|
11
|
+
used: number
|
|
12
|
+
limit: number
|
|
13
|
+
/** Header content shown before the "{used}/{limit}{unit}" count. */
|
|
14
|
+
label?: JSX.Element
|
|
15
|
+
/** Appended to displayed numbers, e.g. `' tokens'` or `'%'`. */
|
|
16
|
+
unit?: string
|
|
17
|
+
/** Fraction of `limit` (0–1) at which the fill switches to the warning tone. @default 0.85 */
|
|
18
|
+
warnAt?: number
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Labeled consumption bar: a header row with `label` and the raw
|
|
24
|
+
* "{used}/{limit}{unit}" count, a track whose fill width is `used / limit`
|
|
25
|
+
* (clamped to 0–100%) and switches to a warning tone once that ratio reaches
|
|
26
|
+
* `warnAt`, and a small "{remaining} left" line underneath. Built on Kobalte's
|
|
27
|
+
* `Meter` primitive, so it carries the same `role="meter"` a11y semantics as
|
|
28
|
+
* {@link Meter}.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <UsageMeter used={92} limit={100} label="API requests" unit=" req" warnAt={0.9} />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function UsageMeter(props: UsageMeterProps): JSX.Element {
|
|
36
|
+
const unit = () => props.unit ?? ''
|
|
37
|
+
const ratio = () => (props.limit > 0 ? props.used / props.limit : 0)
|
|
38
|
+
const percent = () => Math.max(0, Math.min(100, ratio() * 100))
|
|
39
|
+
const isWarning = () => ratio() >= (props.warnAt ?? 0.85)
|
|
40
|
+
const remaining = () => Math.max(0, props.limit - props.used)
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<KMeter
|
|
44
|
+
value={props.used}
|
|
45
|
+
minValue={0}
|
|
46
|
+
maxValue={props.limit}
|
|
47
|
+
getValueLabel={() => `${props.used}/${props.limit}${unit()}`}
|
|
48
|
+
class={cn('flex flex-col gap-1.5', props.class)}
|
|
49
|
+
>
|
|
50
|
+
<div class="flex items-center justify-between text-sm text-foreground">
|
|
51
|
+
<Show when={props.label}>
|
|
52
|
+
<KMeter.Label>{props.label}</KMeter.Label>
|
|
53
|
+
</Show>
|
|
54
|
+
<KMeter.ValueLabel class="ml-auto tabular-nums text-muted-foreground" />
|
|
55
|
+
</div>
|
|
56
|
+
<KMeter.Track class="h-2 overflow-hidden rounded-full bg-muted">
|
|
57
|
+
<KMeter.Fill
|
|
58
|
+
class={cn(
|
|
59
|
+
'h-full rounded-full transition-all duration-500',
|
|
60
|
+
isWarning() ? 'bg-amber-500' : 'bg-primary',
|
|
61
|
+
)}
|
|
62
|
+
style={{ width: `${percent()}%` }}
|
|
63
|
+
/>
|
|
64
|
+
</KMeter.Track>
|
|
65
|
+
<p class="text-xs text-muted-foreground">
|
|
66
|
+
{remaining()}
|
|
67
|
+
{unit()} left
|
|
68
|
+
</p>
|
|
69
|
+
</KMeter>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -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
|
+
}
|