@boyernick/standard-ui-react 0.1.1-canary.10 → 0.1.1-canary.12
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/package.json +1 -1
- package/src/icons.tsx +10 -0
- package/src/index.ts +6 -0
- package/src/kbd.tsx +10 -4
- package/src/menubar.tsx +5 -0
- package/src/navigation-menu.tsx +6 -1
- package/src/progress.tsx +5 -0
- package/src/tooltip.tsx +36 -3
- package/src/video-player.tsx +140 -105
package/package.json
CHANGED
package/src/icons.tsx
CHANGED
|
@@ -16,15 +16,20 @@ import { IconDotGrid1x3Horizontal as IconDotGrid1x3HorizontalBase } from "@centr
|
|
|
16
16
|
import { IconExclamationCircle as IconExclamationCircleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconExclamationCircle"
|
|
17
17
|
import { IconExclamationTriangle as IconExclamationTriangleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconExclamationTriangle"
|
|
18
18
|
import { IconHome as IconHomeBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconHome"
|
|
19
|
+
import { IconFullScreen as IconFullScreenBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconFullScreen"
|
|
19
20
|
import { IconMagnifyingGlass as IconMagnifyingGlassBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMagnifyingGlass"
|
|
20
21
|
import { IconMinusMedium as IconMinusBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMinusMedium"
|
|
22
|
+
import { IconPause as IconPauseBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPause"
|
|
21
23
|
import { IconMoon as IconMoonBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMoon"
|
|
22
24
|
import { IconPeople as IconPeopleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPeople"
|
|
25
|
+
import { IconPlay as IconPlayBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlay"
|
|
23
26
|
import { IconPlusMedium as IconPlusBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlusMedium"
|
|
24
27
|
import { IconSettingsGear1 as IconSettingsGear1Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSettingsGear1"
|
|
25
28
|
import { IconSquareBehindSquare6 as IconSquareBehindSquare6Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSquareBehindSquare6"
|
|
26
29
|
import { IconStar as IconStarBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconStar"
|
|
27
30
|
import { IconSun as IconSunBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSun"
|
|
31
|
+
import { IconVolumeFull as IconVolumeFullBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeFull"
|
|
32
|
+
import { IconVolumeOff as IconVolumeOffBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeOff"
|
|
28
33
|
import { IconX as IconXBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconX"
|
|
29
34
|
import type { ComponentType } from "react"
|
|
30
35
|
|
|
@@ -73,13 +78,16 @@ export const IconExclamationCircle = withCentralIconDefaults(
|
|
|
73
78
|
export const IconExclamationTriangle = withCentralIconDefaults(
|
|
74
79
|
IconExclamationTriangleBase,
|
|
75
80
|
)
|
|
81
|
+
export const IconFullScreen = withCentralIconDefaults(IconFullScreenBase)
|
|
76
82
|
export const IconHome = withCentralIconDefaults(IconHomeBase)
|
|
77
83
|
export const IconMagnifyingGlass = withCentralIconDefaults(
|
|
78
84
|
IconMagnifyingGlassBase,
|
|
79
85
|
)
|
|
80
86
|
export const IconMinus = withCentralIconDefaults(IconMinusBase)
|
|
87
|
+
export const IconPause = withCentralIconDefaults(IconPauseBase)
|
|
81
88
|
export const IconMoon = withCentralIconDefaults(IconMoonBase)
|
|
82
89
|
export const IconPeople = withCentralIconDefaults(IconPeopleBase)
|
|
90
|
+
export const IconPlay = withCentralIconDefaults(IconPlayBase)
|
|
83
91
|
export const IconPlus = withCentralIconDefaults(IconPlusBase)
|
|
84
92
|
export const IconSettingsGear1 = withCentralIconDefaults(IconSettingsGear1Base)
|
|
85
93
|
export const IconSquareBehindSquare6 = withCentralIconDefaults(
|
|
@@ -87,6 +95,8 @@ export const IconSquareBehindSquare6 = withCentralIconDefaults(
|
|
|
87
95
|
)
|
|
88
96
|
export const IconStar = withCentralIconDefaults(IconStarBase)
|
|
89
97
|
export const IconSun = withCentralIconDefaults(IconSunBase)
|
|
98
|
+
export const IconVolumeFull = withCentralIconDefaults(IconVolumeFullBase)
|
|
99
|
+
export const IconVolumeOff = withCentralIconDefaults(IconVolumeOffBase)
|
|
90
100
|
export const IconX = withCentralIconDefaults(IconXBase)
|
|
91
101
|
|
|
92
102
|
export const iconGallery = [
|
package/src/index.ts
CHANGED
|
@@ -791,6 +791,7 @@ export {
|
|
|
791
791
|
TooltipPortal,
|
|
792
792
|
TooltipPositioner,
|
|
793
793
|
TooltipPopup,
|
|
794
|
+
tooltipPopupVariants,
|
|
794
795
|
type TooltipProviderProps,
|
|
795
796
|
type TooltipProps,
|
|
796
797
|
type TooltipTriggerProps,
|
|
@@ -816,16 +817,21 @@ export {
|
|
|
816
817
|
IconDotGrid1x3Horizontal,
|
|
817
818
|
IconExclamationCircle,
|
|
818
819
|
IconExclamationTriangle,
|
|
820
|
+
IconFullScreen,
|
|
819
821
|
IconHome,
|
|
820
822
|
IconMagnifyingGlass,
|
|
821
823
|
IconMinus,
|
|
822
824
|
IconMoon,
|
|
825
|
+
IconPause,
|
|
823
826
|
IconPeople,
|
|
827
|
+
IconPlay,
|
|
824
828
|
IconPlus,
|
|
825
829
|
IconSettingsGear1,
|
|
826
830
|
IconSquareBehindSquare6,
|
|
827
831
|
IconStar,
|
|
828
832
|
IconSun,
|
|
833
|
+
IconVolumeFull,
|
|
834
|
+
IconVolumeOff,
|
|
829
835
|
IconX,
|
|
830
836
|
type CentralIconProps,
|
|
831
837
|
type CentralIconComponent,
|
package/src/kbd.tsx
CHANGED
|
@@ -3,17 +3,23 @@ import type { ComponentProps } from "react"
|
|
|
3
3
|
import { cn } from "./lib/cn"
|
|
4
4
|
|
|
5
5
|
const kbdVariants = cva(
|
|
6
|
+
// `text-xs` carries the size, line-height and tracking; the weight is set
|
|
7
|
+
// separately because 600 sits above the 400/500 the type scale defines, so
|
|
8
|
+
// there is no `-strong` utility to reach for.
|
|
6
9
|
// The border is bottom-only on purpose: it reads as the lip of a physical
|
|
7
10
|
// key without boxing the glyph in on all four sides, which at 12px turns a
|
|
8
11
|
// keycap into a cramped badge.
|
|
9
|
-
"inline-flex shrink-0 items-center justify-center border-b px-1 font-sans text-xs whitespace-nowrap select-none",
|
|
12
|
+
"inline-flex shrink-0 items-center justify-center border-b px-1 font-sans text-xs font-semibold whitespace-nowrap select-none",
|
|
10
13
|
{
|
|
11
14
|
variants: {
|
|
12
15
|
variant: {
|
|
13
16
|
default: "border-border-primary bg-background-tertiary text-fg-secondary",
|
|
14
|
-
// The default fill is a light grey that vanishes on a dark surface,
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
+
// The default fill is a light grey that vanishes on a dark surface, so
|
|
18
|
+
// an inverted keycap tints with the inverted foreground instead. It has
|
|
19
|
+
// to be that token rather than a literal `white`: the grey ramp flips
|
|
20
|
+
// under `.dark`, so a hardcoded white wash would sit invisibly on the
|
|
21
|
+
// now-light inverted surface.
|
|
22
|
+
inverted: "border-fg-inverted/10 bg-fg-inverted/10 text-fg-inverted",
|
|
17
23
|
},
|
|
18
24
|
size: {
|
|
19
25
|
sm: "h-4 min-w-3.5 rounded-2xs",
|
package/src/menubar.tsx
CHANGED
|
@@ -10,6 +10,11 @@ export const Menubar = ({ className, ...props }: MenubarProps) => (
|
|
|
10
10
|
<BaseMenubar
|
|
11
11
|
className={cn(
|
|
12
12
|
"inline-flex items-center gap-0.5 rounded-lg border border-border-primary bg-background-secondary p-0.5",
|
|
13
|
+
// Base UI sets data-orientation and handles the arrow keys, but the
|
|
14
|
+
// layout is ours: without this a vertical menubar still renders as a
|
|
15
|
+
// row, so the prop looks supported and does nothing.
|
|
16
|
+
"data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
|
|
17
|
+
"data-[orientation=vertical]:[&_button]:justify-start",
|
|
13
18
|
"[&_button]:inline-flex [&_button]:h-8 [&_button]:cursor-pointer [&_button]:items-center [&_button]:justify-center [&_button]:gap-1.5 [&_button]:rounded-md [&_button]:px-2.5 [&_button]:text-sm [&_button]:text-fg-secondary [&_button]:outline-none",
|
|
14
19
|
"[&_button]:hover:bg-background-tertiary [&_button]:hover:text-fg-primary",
|
|
15
20
|
"[&_button]:focus-visible:border-ring [&_button]:focus-visible:ring-[3px] [&_button]:focus-visible:ring-offset-1 [&_button]:focus-visible:ring-offset-background-primary [&_button]:focus-visible:ring-ring/20",
|
package/src/navigation-menu.tsx
CHANGED
|
@@ -46,7 +46,12 @@ export type NavigationMenuIconProps = ComponentProps<
|
|
|
46
46
|
>
|
|
47
47
|
|
|
48
48
|
const navigationMenuTriggerClassName = cn(
|
|
49
|
-
|
|
49
|
+
// flex-row and py-0 are here for the link case. This class is exported so
|
|
50
|
+
// a plain link can sit in the bar beside real triggers, but Link's own
|
|
51
|
+
// base is flex-col with p-2.5 — without overriding the direction and the
|
|
52
|
+
// vertical padding, tailwind-merge leaves both, and the link renders as a
|
|
53
|
+
// top-padded column whose text sits below the triggers next to it.
|
|
54
|
+
"inline-flex h-9 cursor-pointer flex-row items-center gap-1 rounded-md px-3 py-0 text-sm text-fg-secondary outline-none",
|
|
50
55
|
motion.colors,
|
|
51
56
|
"hover:bg-background-tertiary hover:text-fg-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-popup-open:bg-background-tertiary data-popup-open:text-fg-primary",
|
|
52
57
|
)
|
package/src/progress.tsx
CHANGED
|
@@ -53,6 +53,11 @@ export const ProgressIndicator = ({
|
|
|
53
53
|
<BaseProgress.Indicator
|
|
54
54
|
className={cn(
|
|
55
55
|
"h-full rounded-full bg-brand-primary transition-[width] duration-[var(--duration-lg)] ease-enter motion-reduce:transition-none",
|
|
56
|
+
// Base UI flags a null value with data-indeterminate and leaves the
|
|
57
|
+
// indicator full width, so without this the bar reads as complete.
|
|
58
|
+
// A quarter-width stripe crossing the track on the passive curve —
|
|
59
|
+
// ambient, system-initiated movement.
|
|
60
|
+
"data-indeterminate:w-1/4 data-indeterminate:animate-[progress-indeterminate_1.4s_var(--ease-passive)_infinite] motion-reduce:animate-none",
|
|
56
61
|
className,
|
|
57
62
|
)}
|
|
58
63
|
{...props}
|
package/src/tooltip.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
5
|
import type { ComponentProps } from "react"
|
|
5
6
|
import { cn } from "./lib/cn"
|
|
6
7
|
import { motion } from "./lib/motion"
|
|
@@ -12,7 +13,8 @@ export type TooltipPortalProps = ComponentProps<typeof BaseTooltip.Portal>
|
|
|
12
13
|
export type TooltipPositionerProps = ComponentProps<
|
|
13
14
|
typeof BaseTooltip.Positioner
|
|
14
15
|
>
|
|
15
|
-
export type TooltipPopupProps = ComponentProps<typeof BaseTooltip.Popup>
|
|
16
|
+
export type TooltipPopupProps = ComponentProps<typeof BaseTooltip.Popup> &
|
|
17
|
+
VariantProps<typeof tooltipPopupVariants>
|
|
16
18
|
|
|
17
19
|
export const TooltipProvider = (props: TooltipProviderProps) => (
|
|
18
20
|
<BaseTooltip.Provider {...props} />
|
|
@@ -43,13 +45,44 @@ export const TooltipPositioner = ({
|
|
|
43
45
|
/>
|
|
44
46
|
)
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
// Neither variant carries a border — `shadow-md` already composes
|
|
49
|
+
// `--elevation-hairline`, so one would stack a second edge. That hairline is
|
|
50
|
+
// what gives the default popup its edge against a same-coloured page.
|
|
51
|
+
const tooltipPopupVariants = cva(
|
|
52
|
+
cn(
|
|
53
|
+
"z-50 max-w-xs rounded-md px-2.5 py-1 text-xs shadow-md outline-none",
|
|
54
|
+
// A trailing keycap already carries its own inset, so the full 10px beside
|
|
55
|
+
// it reads as a gap rather than padding. Matching the vertical inset puts
|
|
56
|
+
// the cap the same distance from every edge it is near.
|
|
57
|
+
"has-[kbd]:pr-1",
|
|
58
|
+
),
|
|
59
|
+
{
|
|
60
|
+
variants: {
|
|
61
|
+
variant: {
|
|
62
|
+
// Sits on the page's own surface: white in light mode, dark in dark.
|
|
63
|
+
default: "bg-surface text-fg-primary",
|
|
64
|
+
// Flipped against the page — dark in light mode, light in dark — for
|
|
65
|
+
// a hint that should read as separate from what it floats over.
|
|
66
|
+
inverted: "bg-surface-inverted text-fg-inverted",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
defaultVariants: { variant: "default" },
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
export const TooltipPopup = ({
|
|
74
|
+
className,
|
|
75
|
+
variant,
|
|
76
|
+
...props
|
|
77
|
+
}: TooltipPopupProps) => (
|
|
47
78
|
<BaseTooltip.Popup
|
|
48
79
|
className={cn(
|
|
49
|
-
|
|
80
|
+
tooltipPopupVariants({ variant }),
|
|
50
81
|
motion.popupAnchor,
|
|
51
82
|
className,
|
|
52
83
|
)}
|
|
53
84
|
{...props}
|
|
54
85
|
/>
|
|
55
86
|
)
|
|
87
|
+
|
|
88
|
+
export { tooltipPopupVariants }
|
package/src/video-player.tsx
CHANGED
|
@@ -8,7 +8,13 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
type ComponentProps,
|
|
10
10
|
} from "react"
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
IconFullScreen,
|
|
13
|
+
IconPause,
|
|
14
|
+
IconPlay,
|
|
15
|
+
IconVolumeFull,
|
|
16
|
+
IconVolumeOff,
|
|
17
|
+
} from "./icons"
|
|
12
18
|
import { cn } from "./lib/cn"
|
|
13
19
|
|
|
14
20
|
const formatTime = (seconds: number) => {
|
|
@@ -18,6 +24,13 @@ const formatTime = (seconds: number) => {
|
|
|
18
24
|
return `${mins}:${secs.toString().padStart(2, "0")}`
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
// The media surface is black in both themes, so everything sitting on it is
|
|
28
|
+
// light regardless of the theme — semantic foreground tokens would invert and
|
|
29
|
+
// disappear against the video. This is the same reasoning as the dialog
|
|
30
|
+
// backdrops, and the opposite of a keycap, which sits on a themed surface.
|
|
31
|
+
const overlayControl =
|
|
32
|
+
"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-white outline-none transition-colors duration-[var(--duration-sm)] ease-enter hover:bg-white/15 focus-visible:ring-2 focus-visible:ring-white/70 motion-reduce:transition-none"
|
|
33
|
+
|
|
21
34
|
export type VideoPlayerProps = ComponentProps<"div"> & {
|
|
22
35
|
src: string
|
|
23
36
|
poster?: string
|
|
@@ -40,6 +53,7 @@ export const VideoPlayer = ({
|
|
|
40
53
|
const [muted, setMuted] = useState(false)
|
|
41
54
|
const [current, setCurrent] = useState(0)
|
|
42
55
|
const [duration, setDuration] = useState(0)
|
|
56
|
+
const [buffered, setBuffered] = useState(0)
|
|
43
57
|
|
|
44
58
|
const handleTogglePlay = useCallback(() => {
|
|
45
59
|
const video = videoRef.current
|
|
@@ -79,163 +93,184 @@ export const VideoPlayer = ({
|
|
|
79
93
|
const video = videoRef.current
|
|
80
94
|
if (!video) return
|
|
81
95
|
|
|
96
|
+
const readBuffered = () => {
|
|
97
|
+
if (video.buffered.length === 0) return
|
|
98
|
+
setBuffered(video.buffered.end(video.buffered.length - 1))
|
|
99
|
+
}
|
|
82
100
|
const handlePlay = () => setPlaying(true)
|
|
83
101
|
const handlePause = () => setPlaying(false)
|
|
84
102
|
const handleTime = () => setCurrent(video.currentTime)
|
|
85
103
|
const handleMeta = () => setDuration(video.duration || 0)
|
|
104
|
+
const handleVolume = () => setMuted(video.muted)
|
|
105
|
+
|
|
106
|
+
// A cached video can be ready before this effect runs, in which case
|
|
107
|
+
// `loadedmetadata` has already fired and the readout would sit at 0:00
|
|
108
|
+
// forever. Seed from the element, then keep up with the events.
|
|
109
|
+
handleMeta()
|
|
110
|
+
handleTime()
|
|
111
|
+
handleVolume()
|
|
112
|
+
readBuffered()
|
|
113
|
+
setPlaying(!video.paused)
|
|
86
114
|
|
|
87
115
|
video.addEventListener("play", handlePlay)
|
|
88
116
|
video.addEventListener("pause", handlePause)
|
|
89
117
|
video.addEventListener("timeupdate", handleTime)
|
|
90
118
|
video.addEventListener("loadedmetadata", handleMeta)
|
|
119
|
+
video.addEventListener("durationchange", handleMeta)
|
|
120
|
+
video.addEventListener("volumechange", handleVolume)
|
|
121
|
+
video.addEventListener("progress", readBuffered)
|
|
91
122
|
|
|
92
123
|
return () => {
|
|
93
124
|
video.removeEventListener("play", handlePlay)
|
|
94
125
|
video.removeEventListener("pause", handlePause)
|
|
95
126
|
video.removeEventListener("timeupdate", handleTime)
|
|
96
127
|
video.removeEventListener("loadedmetadata", handleMeta)
|
|
128
|
+
video.removeEventListener("durationchange", handleMeta)
|
|
129
|
+
video.removeEventListener("volumechange", handleVolume)
|
|
130
|
+
video.removeEventListener("progress", readBuffered)
|
|
97
131
|
}
|
|
98
132
|
}, [])
|
|
99
133
|
|
|
100
|
-
const
|
|
134
|
+
const played = duration > 0 ? (current / duration) * 100 : 0
|
|
135
|
+
const loaded = duration > 0 ? (buffered / duration) * 100 : 0
|
|
101
136
|
|
|
102
137
|
return (
|
|
103
138
|
<div
|
|
104
139
|
data-slot="video-player"
|
|
140
|
+
data-playing={playing || undefined}
|
|
141
|
+
// `group` drives the chrome: it hides while playing and comes back on
|
|
142
|
+
// hover or when anything inside takes focus.
|
|
105
143
|
className={cn(
|
|
106
|
-
"overflow-hidden rounded-xl
|
|
144
|
+
"group relative isolate overflow-hidden rounded-xl bg-black shadow-sm",
|
|
107
145
|
className,
|
|
108
146
|
)}
|
|
109
147
|
{...props}
|
|
110
148
|
>
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
149
|
+
<video
|
|
150
|
+
ref={videoRef}
|
|
151
|
+
src={src}
|
|
152
|
+
poster={poster}
|
|
153
|
+
playsInline
|
|
154
|
+
className="aspect-video w-full cursor-pointer object-cover"
|
|
155
|
+
aria-label={ariaLabel ?? title ?? "Video"}
|
|
156
|
+
onClick={handleTogglePlay}
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
{/* Centred affordance while paused. It is the video's own click target
|
|
160
|
+
underneath, so the badge itself stays inert. */}
|
|
161
|
+
<div
|
|
162
|
+
className={cn(
|
|
163
|
+
"pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
|
|
164
|
+
playing ? "opacity-0" : "opacity-100",
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
<span className="flex size-14 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
|
|
168
|
+
<IconPlay size={24} className="ml-0.5 size-6" aria-hidden />
|
|
169
|
+
</span>
|
|
128
170
|
</div>
|
|
129
171
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
{title ? (
|
|
173
|
+
<div
|
|
174
|
+
className={cn(
|
|
175
|
+
"pointer-events-none absolute inset-x-0 top-0 bg-gradient-to-b from-black/60 to-transparent px-4 pt-3 pb-8 transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
|
|
176
|
+
"group-hover:opacity-100 group-focus-within:opacity-100",
|
|
177
|
+
// Nothing hovers on a touch screen, so the chrome would never come
|
|
178
|
+
// back once playback started. Keep it up there instead.
|
|
179
|
+
"[@media(hover:none)]:opacity-100",
|
|
180
|
+
playing ? "opacity-0" : "opacity-100",
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
<p className="text-sm-strong truncate text-white">{title}</p>
|
|
184
|
+
</div>
|
|
185
|
+
) : null}
|
|
134
186
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
187
|
+
<div
|
|
188
|
+
className={cn(
|
|
189
|
+
"absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-gradient-to-t from-black/75 via-black/45 to-transparent px-3 pt-10 pb-2 transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
|
|
190
|
+
"group-hover:opacity-100 group-focus-within:opacity-100",
|
|
191
|
+
// Nothing hovers on a touch screen, so the chrome would never come
|
|
192
|
+
// back once playback started. Keep it up there instead.
|
|
193
|
+
"[@media(hover:none)]:opacity-100",
|
|
194
|
+
playing ? "opacity-0" : "opacity-100",
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
<label className="sr-only" htmlFor={seekId}>
|
|
198
|
+
Seek
|
|
199
|
+
</label>
|
|
200
|
+
<div className="relative flex h-4 items-center">
|
|
201
|
+
{/* Track, buffered fill and played fill sit under the input, which
|
|
202
|
+
stays transparent so its thumb is the only thing it paints. */}
|
|
203
|
+
<span
|
|
204
|
+
aria-hidden
|
|
205
|
+
className="pointer-events-none absolute inset-x-0 h-1 rounded-full bg-white/25"
|
|
206
|
+
/>
|
|
207
|
+
<span
|
|
208
|
+
aria-hidden
|
|
209
|
+
className="pointer-events-none absolute left-0 h-1 rounded-full bg-white/35"
|
|
210
|
+
style={{ width: `${loaded}%` }}
|
|
211
|
+
/>
|
|
212
|
+
<span
|
|
213
|
+
aria-hidden
|
|
214
|
+
className="pointer-events-none absolute left-0 h-1 rounded-full bg-white"
|
|
215
|
+
style={{ width: `${played}%` }}
|
|
216
|
+
/>
|
|
139
217
|
<input
|
|
140
218
|
id={seekId}
|
|
141
219
|
type="range"
|
|
142
220
|
min={0}
|
|
143
221
|
max={duration || 0}
|
|
144
|
-
step={0.
|
|
222
|
+
step={0.01}
|
|
145
223
|
value={current}
|
|
146
224
|
onChange={(event) => handleSeek(Number(event.target.value))}
|
|
147
|
-
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-background-quaternary accent-[var(--brand-primary)] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand-primary"
|
|
148
|
-
style={{
|
|
149
|
-
background: `linear-gradient(to right, var(--brand-primary) ${progress}%, var(--background-quaternary) ${progress}%)`,
|
|
150
|
-
}}
|
|
151
225
|
aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
|
|
226
|
+
className={cn(
|
|
227
|
+
"relative m-0 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none",
|
|
228
|
+
// Both vendor thumbs, or Firefox falls back to a default one.
|
|
229
|
+
"[&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm",
|
|
230
|
+
"[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white",
|
|
231
|
+
"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
|
|
232
|
+
)}
|
|
152
233
|
/>
|
|
153
234
|
</div>
|
|
154
235
|
|
|
155
|
-
<div className="flex items-center gap-
|
|
156
|
-
<
|
|
236
|
+
<div className="flex items-center gap-1">
|
|
237
|
+
<button
|
|
157
238
|
type="button"
|
|
158
|
-
|
|
159
|
-
size="sm"
|
|
160
|
-
iconOnly
|
|
161
|
-
rounded
|
|
239
|
+
className={overlayControl}
|
|
162
240
|
aria-label={playing ? "Pause" : "Play"}
|
|
163
241
|
onClick={handleTogglePlay}
|
|
164
242
|
>
|
|
165
|
-
{playing ?
|
|
166
|
-
|
|
167
|
-
|
|
243
|
+
{playing ? (
|
|
244
|
+
<IconPause size={18} className="size-4.5" aria-hidden />
|
|
245
|
+
) : (
|
|
246
|
+
<IconPlay size={18} className="size-4.5" aria-hidden />
|
|
247
|
+
)}
|
|
248
|
+
</button>
|
|
249
|
+
<button
|
|
168
250
|
type="button"
|
|
169
|
-
|
|
170
|
-
size="sm"
|
|
171
|
-
iconOnly
|
|
172
|
-
rounded
|
|
251
|
+
className={overlayControl}
|
|
173
252
|
aria-label={muted ? "Unmute" : "Mute"}
|
|
174
253
|
onClick={handleToggleMute}
|
|
175
254
|
>
|
|
176
|
-
{muted ?
|
|
177
|
-
|
|
178
|
-
|
|
255
|
+
{muted ? (
|
|
256
|
+
<IconVolumeOff size={18} className="size-4.5" aria-hidden />
|
|
257
|
+
) : (
|
|
258
|
+
<IconVolumeFull size={18} className="size-4.5" aria-hidden />
|
|
259
|
+
)}
|
|
260
|
+
</button>
|
|
261
|
+
<span className="text-xs ml-1 font-mono text-white/80 tabular-nums">
|
|
179
262
|
{formatTime(current)} / {formatTime(duration)}
|
|
180
263
|
</span>
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
onClick={handleFullscreen}
|
|
190
|
-
>
|
|
191
|
-
<FullscreenIcon />
|
|
192
|
-
</Button>
|
|
193
|
-
</div>
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
className={cn(overlayControl, "ml-auto")}
|
|
267
|
+
aria-label="Fullscreen"
|
|
268
|
+
onClick={handleFullscreen}
|
|
269
|
+
>
|
|
270
|
+
<IconFullScreen size={18} className="size-4.5" aria-hidden />
|
|
271
|
+
</button>
|
|
194
272
|
</div>
|
|
195
273
|
</div>
|
|
196
274
|
</div>
|
|
197
275
|
)
|
|
198
276
|
}
|
|
199
|
-
|
|
200
|
-
const PlayIcon = () => (
|
|
201
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
202
|
-
<path d="M9 7.5v9l8-4.5-8-4.5Z" fill="currentColor" />
|
|
203
|
-
</svg>
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
const PauseIcon = () => (
|
|
207
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
208
|
-
<path d="M8 7h3v10H8V7Zm5 0h3v10h-3V7Z" fill="currentColor" />
|
|
209
|
-
</svg>
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
const VolumeIcon = () => (
|
|
213
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
214
|
-
<path
|
|
215
|
-
d="M4 10v4h3l4 3V7L7 10H4Zm11.5 2a2.5 2.5 0 0 0-1.5-2.3v4.6A2.5 2.5 0 0 0 15.5 12Zm0-5.5v1.6a4 4 0 0 1 0 7.8v1.6a5.5 5.5 0 0 0 0-11Z"
|
|
216
|
-
fill="currentColor"
|
|
217
|
-
/>
|
|
218
|
-
</svg>
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
const MuteIcon = () => (
|
|
222
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
223
|
-
<path
|
|
224
|
-
d="M4 10v4h3l4 3V7L7 10H4Zm11.2-.8 1.4-1.4 1.4 1.4 1.4-1.4 1.4 1.4-1.4 1.4 1.4 1.4-1.4 1.4-1.4-1.4-1.4 1.4-1.4-1.4 1.4-1.4-1.4-1.4Z"
|
|
225
|
-
fill="currentColor"
|
|
226
|
-
/>
|
|
227
|
-
</svg>
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
const FullscreenIcon = () => (
|
|
231
|
-
<svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
|
|
232
|
-
<path
|
|
233
|
-
d="M7 9V7h3v2H7Zm7 0V7h3v2h-3ZM7 17v-2h3v2H7Zm7 0v-2h3v2h-3Z"
|
|
234
|
-
fill="currentColor"
|
|
235
|
-
/>
|
|
236
|
-
<path
|
|
237
|
-
d="M5 5h5v2H7v3H5V5Zm9 0h5v5h-2V7h-3V5ZM5 14h2v3h3v2H5v-5Zm12 3h-3v2h5v-5h-2v3Z"
|
|
238
|
-
fill="currentColor"
|
|
239
|
-
/>
|
|
240
|
-
</svg>
|
|
241
|
-
)
|