feat: 引入videojs

This commit is contained in:
2026-10-05 20:38:26 -07:00
parent 0203de1168
commit b289a4c3b8
49 changed files with 3186 additions and 2 deletions
+4 -2
View File
@@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
"registries": {
"@videojs": "https://shadcn.videojs.org/r/react/{name}.json"
}
}
+2
View File
@@ -12,6 +12,8 @@
"@base-ui/react": "^1.8.0",
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-virtual": "^3.14.13",
"@videojs/core": "10.0.1",
"@videojs/react": "10.0.1",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.3.2",
+142
View File
@@ -17,6 +17,12 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.14.13
version: 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@videojs/core':
specifier: 10.0.1
version: 10.0.1(react@19.2.8)
'@videojs/react':
specifier: 10.0.1
version: 10.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
axios:
specifier: ^1.20.0
version: 1.20.0
@@ -937,6 +943,81 @@ packages:
resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@videojs/core@10.0.1':
resolution: {integrity: sha512-N+f41wgFcmpe0QUhO0Q9sFHpMxKvr38ojkX5FlxAo3LKDAo2UrG2KIeklte+1VVae3oaJfP6J/KYtrRRObRxxw==}
'@videojs/media@10.0.1':
resolution: {integrity: sha512-hMC2aS/8PovwJBi8y2qShC/amYfH6SBce7SZAM54qzRq8hl4FwWGFeUMMmXp8x4agRxLuCSqGqZg/EoGH2I5mQ==}
'@videojs/native-hls-video@10.0.1':
resolution: {integrity: sha512-MElgeDaxOJUj+OjHV3++TuW8TTgipTuzlRkfFDMMbnutlPLkSlX3VKhXo5ZNPhhXkt47oBVx4RGnAQJLWzilPw==}
'@videojs/react@10.0.1':
resolution: {integrity: sha512-qK2GrVnWM2Hadcj6bWHS+UGThTtFD5VNfUrwGoFA6+D2/iW1Q8VVIU6N3+JY0YEq8dazvPVMPpSVvnmfQmPK0w==}
peerDependencies:
'@videojs/cloudflare-video': 10.0.1
'@videojs/dash-video': 10.0.1
'@videojs/google-cast': 10.0.1
'@videojs/hlsjs-video': 10.0.1
'@videojs/mux-audio': 10.0.1
'@videojs/mux-data': 10.0.1
'@videojs/mux-video': 10.0.1
'@videojs/shaka-video': 10.0.1
'@videojs/spotify-audio': 10.0.1
'@videojs/tiktok-video': 10.0.1
'@videojs/twitch-video': 10.0.1
'@videojs/vimeo-video': 10.0.1
'@videojs/wistia-video': 10.0.1
'@videojs/youtube-video': 10.0.1
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@videojs/cloudflare-video':
optional: true
'@videojs/dash-video':
optional: true
'@videojs/google-cast':
optional: true
'@videojs/hlsjs-video':
optional: true
'@videojs/mux-audio':
optional: true
'@videojs/mux-data':
optional: true
'@videojs/mux-video':
optional: true
'@videojs/shaka-video':
optional: true
'@videojs/spotify-audio':
optional: true
'@videojs/tiktok-video':
optional: true
'@videojs/twitch-video':
optional: true
'@videojs/vimeo-video':
optional: true
'@videojs/wistia-video':
optional: true
'@videojs/youtube-video':
optional: true
'@videojs/spf@10.0.1':
resolution: {integrity: sha512-1HUTI1W++EKZuO3MaklFdEU/cEUhkJlKKuQx0Xi3VwjeHgVToAz7b1ySDH903KluPQy6cAGqXpro5Wt00y4DYA==}
'@videojs/store@10.0.1':
resolution: {integrity: sha512-BTkkTRHAU0XaZi+n3yWhtQJbe0BNjQLBd414u/s/nMZDz5bc1fp6b/voNbNriuSMd0Ro+ZmyzfPaMskiCa6GUQ==}
peerDependencies:
'@videojs/element': 10.0.1
react: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@videojs/element':
optional: true
react:
optional: true
'@videojs/utils@10.0.1':
resolution: {integrity: sha512-S7WO9lPfyCz5YQYWtpE42JZTtJKUaiWaAZGAa+VLoBiFMfpoogCloSP4pjChTYcQ6sGcLlQJNxVKhM1RvI/Dig==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -2656,6 +2737,11 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
react-compiler-runtime@1.0.0:
resolution: {integrity: sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental
react-dom@19.2.8:
resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
peerDependencies:
@@ -2831,6 +2917,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
signal-polyfill@0.2.2:
resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -4118,6 +4207,53 @@ snapshots:
'@typescript-eslint/types': 8.70.1
eslint-visitor-keys: 5.0.1
'@videojs/core@10.0.1(react@19.2.8)':
dependencies:
'@videojs/media': 10.0.1
'@videojs/store': 10.0.1(react@19.2.8)
'@videojs/utils': 10.0.1
transitivePeerDependencies:
- '@videojs/element'
- react
'@videojs/media@10.0.1':
dependencies:
'@videojs/utils': 10.0.1
'@videojs/native-hls-video@10.0.1':
dependencies:
'@videojs/media': 10.0.1
'@videojs/utils': 10.0.1
'@videojs/react@10.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@videojs/core': 10.0.1(react@19.2.8)
'@videojs/media': 10.0.1
'@videojs/native-hls-video': 10.0.1
'@videojs/spf': 10.0.1
'@videojs/store': 10.0.1(react@19.2.8)
'@videojs/utils': 10.0.1
react: 19.2.8
react-compiler-runtime: 1.0.0(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
transitivePeerDependencies:
- '@videojs/element'
'@videojs/spf@10.0.1':
dependencies:
'@videojs/media': 10.0.1
'@videojs/utils': 10.0.1
signal-polyfill: 0.2.2
'@videojs/store@10.0.1(react@19.2.8)':
dependencies:
'@videojs/utils': 10.0.1
react-compiler-runtime: 1.0.0(react@19.2.8)
optionalDependencies:
react: 19.2.8
'@videojs/utils@10.0.1': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -5941,6 +6077,10 @@ snapshots:
iconv-lite: 0.7.3
unpipe: 1.0.0
react-compiler-runtime@1.0.0(react@19.2.8):
dependencies:
react: 19.2.8
react-dom@19.2.8(react@19.2.8):
dependencies:
react: 19.2.8
@@ -6219,6 +6359,8 @@ snapshots:
signal-exit@4.1.0: {}
signal-polyfill@0.2.2: {}
sisteransi@1.0.5: {}
slash@4.0.0: {}
+365
View File
@@ -50,6 +50,81 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--color-media-accent: var(--media-accent);
--color-media-background: var(--media-background);
--color-media-border: var(--media-border);
--color-media-controls: var(--media-controls);
--color-media-controls-foreground: var(--media-controls-foreground);
--color-media-foreground: var(--media-foreground);
--color-media-live: var(--media-live-color);
--color-media-muted: var(--media-muted);
--color-media-muted-foreground: var(--media-muted-foreground);
--color-media-popover: var(--media-popover);
--color-media-popover-foreground: var(--media-popover-foreground);
--color-media-primary: var(--media-primary);
--color-media-primary-foreground: var(--media-primary-foreground);
--color-media-ring: var(--media-ring);
--color-media-backdrop: var(--media-backdrop);
--container-media-280: 17.5rem;
--container-media-xs: 20rem;
--container-media-360: 22.5rem;
--container-media-sm: 24rem;
--container-media-md: 28rem;
--container-media-lg: 32rem;
--container-media-xl: 36rem;
--container-media-2xl: 42rem;
--container-media-dialog: var(--media-dialog-max-width);
--text-media-xs: .7em;
--text-media-sm: calc(var(--media-spacing) * 2.75);
--text-media: calc(var(--media-spacing) * 3.25);
--text-media-lg: calc(var(--media-spacing) * 3.75);
--text-media-xl: calc(var(--media-spacing) * 4.25);
--spacing-media-icon-sm: calc(var(--media-icon-size, calc(var(--media-spacing) * 4.5)) * 7 / 9);
--spacing-media-icon: var(--media-icon-size, calc(var(--media-spacing) * 4.5));
--spacing-media-icon-lg: calc(var(--media-icon-size, calc(var(--media-spacing) * 4.5)) * 1.5);
--spacing-media-icon-xl: calc(var(--media-icon-size, calc(var(--media-spacing) * 4.5)) * 2);
--spacing-media-control: var(--media-control-size);
--spacing-media-dialog-width: var(--media-dialog-width);
--radius-media-control: var(--media-control-radius);
--radius-media-controls: var(--media-controls-radius);
--radius-media-dialog: var(--media-dialog-radius);
--radius-media-menu-item: var(--media-menu-item-radius);
--radius-media-pill: 9999px;
--radius-media-player: var(--media-video-border-radius);
--radius-media-popup: var(--media-popup-radius);
--drop-shadow-media-icon: 0 1px 0 var(--media-shadow-current-color);
--shadow-media-hairline: 0 0 0 1px var(--media-border);
--shadow-media-separator: var(--media-shadow-separator);
--shadow-media-thumb: var(--media-shadow-thumb);
--shadow-media-tooltip: var(--media-shadow-tooltip);
--text-shadow-media: 0 1px 0 var(--media-shadow-current-color);
--text-shadow-media-dialog: var(--media-text-shadow-dialog);
--backdrop-filter-media-indicator: var(--media-backdrop-filter-indicator);
--backdrop-filter-media-dialog: var(--media-backdrop-filter-dialog);
--duration-media-instant: var(--media-duration-instant);
--duration-media-fast: var(--media-duration-fast);
--duration-media-base: var(--media-duration);
--duration-media-slow: var(--media-duration-slow);
--duration-media-slower: var(--media-duration-slower);
--duration-media-controls: var(--media-duration-controls);
--duration-media-controls-enter: calc(var(--media-duration-controls) / 2);
--duration-media-menu: var(--media-duration-menu);
--duration-media-dialog: var(--media-duration-dialog);
--duration-media-indicator: var(--media-duration-indicator);
--duration-media-slider: var(--media-duration-slider);
--delay-media-dialog: var(--media-delay-dialog);
--scale-media-hidden: var(--media-hidden-scale);
--scale-media-hidden-indicator: var(--media-hidden-indicator-scale);
--scale-media-hidden-playback: var(--media-hidden-playback-scale);
--scale-media-hidden-icon: var(--media-hidden-icon-scale);
--scale-media-hidden-popup: var(--media-hidden-popup-scale);
--scale-media-hidden-preview: var(--media-hidden-preview-scale);
--blur-media-hidden: var(--media-hidden-blur);
--blur-media-hidden-popup: var(--media-hidden-popup-blur);
--spacing-media-hidden-offset: var(--media-hidden-offset);
--spacing-media-hidden-indicator-offset: var(--media-hidden-indicator-offset);
--spacing-media-hidden-preview-offset: var(--media-hidden-preview-offset);
--spacing-media-hidden-seek-offset: var(--media-hidden-seek-offset);
}
/* 仅深色主题::root 与 .dark 使用同一套 TorBox 品牌色,规范见 docs/design-system.md */
@@ -151,3 +226,293 @@
@apply font-sans;
}
}
@utility duration-media-* {
transition-duration: --value(--duration-media-*);
}
@utility delay-media-* {
transition-delay: --value(--delay-media-*);
}
@utility backdrop-filter-media-* {
backdrop-filter: --value(--backdrop-filter-media-*);
}
@utility font-media {
font-family: var(--media-font-family, "Inter Variable", Inter, ui-sans-serif, system-ui, sans-serif);
}
@custom-variant media-high-contrast {
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
@slot ;
}
}
@custom-variant media-280 {
@container media-root (width > theme(--container-media-280)) {
@slot ;
}
}
@custom-variant media-max-280 {
@container media-root (width <= theme(--container-media-280)) {
@slot ;
}
}
@custom-variant media-xs {
@container media-root (width >= theme(--container-media-xs)) {
@slot ;
}
}
@custom-variant media-max-xs {
@container media-root (width < theme(--container-media-xs)) {
@slot ;
}
}
@custom-variant media-360 {
@container media-root (width >= theme(--container-media-360)) {
@slot ;
}
}
@custom-variant media-max-360 {
@container media-root (width < theme(--container-media-360)) {
@slot ;
}
}
@custom-variant media-sm {
@container media-root (width >= theme(--container-media-sm)) {
@slot ;
}
}
@custom-variant media-max-sm {
@container media-root (width < theme(--container-media-sm)) {
@slot ;
}
}
@custom-variant media-md {
@container media-root (width >= theme(--container-media-md)) {
@slot ;
}
}
@custom-variant media-max-md {
@container media-root (width < theme(--container-media-md)) {
@slot ;
}
}
@custom-variant media-lg {
@container media-root (width >= theme(--container-media-lg)) {
@slot ;
}
}
@custom-variant media-max-lg {
@container media-root (width < theme(--container-media-lg)) {
@slot ;
}
}
@custom-variant media-xl {
@container media-root (width >= theme(--container-media-xl)) {
@slot ;
}
}
@custom-variant media-max-xl {
@container media-root (width < theme(--container-media-xl)) {
@slot ;
}
}
@custom-variant media-2xl {
@container media-root (width >= theme(--container-media-2xl)) {
@slot ;
}
}
@custom-variant media-max-2xl {
@container media-root (width < theme(--container-media-2xl)) {
@slot ;
}
}
@custom-variant media-highlighted {
&:where(:focus-visible, [aria-expanded="true"], [data-highlighted]):where(:not([aria-disabled="true"])) {
@slot;
}
}
@custom-variant media-transitioning {
&:is([data-starting-style], [data-ending-style]) {
@slot;
}
}
@custom-variant media-anchored {
@supports (top: anchor(top)) {
@slot ;
}
}
@utility highlight-media {
background-color: var(--media-accent);
color: var(--media-accent-foreground);
}
@utility surface-media {
box-shadow: 0 0 0 1px var(--media-surface-border, var(--media-border)), var(--media-shadow-surface);
backdrop-filter: var(--media-backdrop-filter-surface);
}
@utility surface-media-inset {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
border-radius: inherit;
box-shadow: var(--media-shadow-surface-inset);
}
@utility surface-media-none {
background-color: transparent;
box-shadow: none;
backdrop-filter: none;
}
@utility focus-ring-media {
outline: 2px solid transparent;
outline-offset: -2px;
}
@utility mask-media-volume {
mask-repeat: no-repeat;
mask-position: 100% 0;
transition: mask-position var(--media-duration-instant) ease-out;
}
@utility mask-media-volume-open {
mask-image: linear-gradient(to right, transparent 10%, black 25%, black 100%);
mask-position: 0 0;
}
@utility nudge-media {
transform: translateX(0.25px);
transition: transform 300ms linear(0, -24 20%, 16 40%, -8 60%, 4 80%, 1);
}
@utility layer-media {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
}
@utility object-media {
object-fit: var(--media-object-fit, contain);
object-position: var(--media-object-position, center);
}
@utility transition-media-popup {
transition-property: opacity, filter, transform, scale;
transition-duration: var(--media-duration-fast);
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
@utility transition-media-menu-resize {
transition-property: opacity, filter, transform, scale, width, height;
transition-duration: var(--media-duration-fast), var(--media-duration-fast), var(--media-duration-fast), var(--media-duration-fast), var(--media-duration-menu), var(--media-duration-menu);
}
@utility motion-media-* {
transition-property: --value([*]);
will-change: --value([*]);
}
@utility anchor-media-highlight {
position: absolute;
position-anchor: --media-menu-item-highlight-anchor;
inset: anchor(inside);
overflow-anchor: none;
pointer-events: none;
border-radius: var(--media-menu-item-radius);
background-color: var(--media-accent);
transition: inset var(--media-duration-fast) ease-in-out;
}
@utility clip-media-x-* {
left: calc(var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0));
right: max( calc(100% - var(--value([*]), 0%)), calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) );
clip-path: inset(-1px 0);
}
@utility clip-media-y-* {
bottom: calc(var(--media-slider-chapter-start, 0%) + var(--media-spacing) * var(--media-chapter-inset-start, 0));
top: max( calc(100% - var(--value([*]), 0%)), calc(100% - var(--media-slider-chapter-end, 100%) + var(--media-spacing) * var(--media-chapter-inset-end, 0)) );
clip-path: inset(0 -1px);
}
@utility clip-media-chapter-x {
clip-path: inset(0 calc(100% - var(--media-slider-chapter-end)) 0 var(--media-slider-chapter-start));
}
@utility clip-media-chapter-y {
clip-path: inset(calc(100% - var(--media-slider-chapter-end)) 0 var(--media-slider-chapter-start) 0);
}
@utility clip-media-chapter-track-x {
left: calc(var(--media-slider-chapter-start) + var(--media-spacing) * var(--media-chapter-inset-start));
right: calc(100% - var(--media-slider-chapter-end) + var(--media-spacing) * var(--media-chapter-inset-end));
clip-path: inset(-1px);
}
@utility clip-media-chapter-track-y {
top: calc(100% - var(--media-slider-chapter-end) + var(--media-spacing) * var(--media-chapter-inset-end));
bottom: calc(var(--media-slider-chapter-start) + var(--media-spacing) * var(--media-chapter-inset-start));
clip-path: inset(-1px);
}
@@ -0,0 +1,2 @@
@import "../base.css";
@import "./theme.css";
@@ -0,0 +1,59 @@
@layer base.theme, base.preset, base.preferences;
@layer base.preset {
/* Light colors for browsers without `light-dark()`; the `@supports` block below adds the dark ones. */
.media-skin:is([data-preset="audio"], [data-preset="live-audio"]) {
--media-background: oklch(1 0 0);
--media-foreground: oklch(0 0 0);
--media-controls: oklch(1 0 0 / 0.5);
--media-controls-foreground: var(--media-foreground);
--media-popover: var(--media-controls);
--media-popover-foreground: var(--media-foreground);
--media-surface-border: oklch(0 0 0 / 0.05);
--media-primary: var(--media-accent-color, var(--media-foreground));
--media-border: oklch(0 0 0 / 0.1);
--media-ring: var(--media-foreground);
--media-default-accent-color: oklch(0 0 0);
--media-default-accent-text-color: oklch(1 0 0);
--media-internal-accent-text-fallback: oklch(1 0 0);
--media-popover-boundary-offset: calc(var(--media-spacing) * 2);
--media-popover-side-offset: calc(var(--media-spacing) * 3);
--media-slider-preview-label-offset: calc(var(--media-spacing) * 10);
}
/*
* Keep `light-dark()` behind this check. A build that lowers it for Safari before 17.5, such as Next.js, leaves it
* alone here but elsewhere rewrites it into variables that only the app's own `color-scheme` rules define.
*/
@supports (color: light-dark(red, red)) {
.media-skin:is([data-preset="audio"], [data-preset="live-audio"]) {
--media-background: light-dark(oklch(1 0 0), oklch(0 0 0));
--media-foreground: light-dark(oklch(0 0 0), oklch(1 0 0));
--media-controls: light-dark(oklch(1 0 0 / 0.5), oklch(0 0 0 / 0.4));
--media-border: light-dark(oklch(0 0 0 / 0.1), oklch(1 0 0 / 0.1));
--media-default-accent-color: light-dark(oklch(0 0 0), oklch(1 0 0));
--media-default-accent-text-color: light-dark(oklch(1 0 0), oklch(0 0 0));
--media-internal-accent-text-fallback: light-dark(oklch(1 0 0), oklch(0 0 0));
}
}
.media-skin:is([data-preset="audio"], [data-preset="live-audio"])[data-theme="neutral"] {
--media-controls: var(--media-background);
--media-popover: var(--media-background);
--media-controls-radius: calc(var(--media-spacing) * 3.5);
--media-popover-boundary-offset: calc(var(--media-spacing) * 3);
}
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
.media-skin:is([data-preset="audio"], [data-preset="live-audio"]) {
--media-controls: var(--media-background);
--media-surface-border: oklch(0 0 0 / 0.05);
}
@supports (color: light-dark(red, red)) {
.media-skin:is([data-preset="audio"], [data-preset="live-audio"]) {
--media-surface-border: light-dark(oklch(0 0 0 / 0.05), transparent);
}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
@layer base, components, utilities;
/* Preset adaptations and preference overrides win over theme tokens regardless of import order. */
@layer base.theme, base.preset, base.preferences;
@import "./themes/theme.css";
@import "./themes/preferences.css";
/* Interpolate progress before chapter bounds are applied. */
@property --media-slider-fill {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --media-slider-buffer {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@layer base {
@scope (.media-skin) {
:scope {
font-style: normal;
font-weight: 400;
text-align: start;
text-transform: none;
letter-spacing: normal;
}
:scope,
*,
::before,
::after {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
button {
font: inherit;
color: inherit;
text-transform: none;
letter-spacing: inherit;
appearance: none;
background: none;
}
img,
svg,
media-icon {
display: block;
max-width: 100%;
}
svg[fill="currentColor"] {
fill: currentColor;
}
svg[fill="none"] {
fill: none;
}
svg[stroke="currentColor"] {
stroke: currentColor;
}
video {
display: block;
width: 100%;
height: 100%;
max-width: 100%;
border-radius: inherit;
object-fit: var(--media-object-fit, contain);
object-position: var(--media-object-position, center);
}
media-icon > svg {
width: 100%;
height: 100%;
}
media-tooltip-group,
media-dialog,
media-alert-dialog,
media-error-dialog,
media-controls {
display: contents;
}
[hidden][hidden] {
/* Hidden must override component display styles. */
display: none !important;
}
}
@media (prefers-reduced-motion: no-preference) {
.media-skin {
interpolate-size: allow-keywords;
}
}
/* Without the Popover API nothing hides a closed popup, so hide it until its component opens it or while it exits. */
@supports not selector(:popover-open) {
:where(.media-skin) [popover]:not([data-open], [data-ending-style]) {
display: none !important;
}
/* Popups stay inside the player without the top layer, so let them extend past the short audio bar. */
.media-skin:is([data-preset="audio"], [data-preset="live-audio"]) {
overflow: visible !important;
}
}
}
@@ -0,0 +1,76 @@
@layer base.theme, base.preset, base.preferences;
@layer base.preferences {
@media (pointer: fine) and (prefers-reduced-motion: no-preference) {
.media-skin:not([data-controls-visible]) {
--media-duration-controls: 300ms;
}
}
@media (pointer: coarse) and (prefers-reduced-motion: no-preference) {
.media-skin:not([data-controls-visible]) {
--media-duration-controls: 150ms;
}
}
@media (prefers-reduced-motion: reduce) {
.media-skin {
--media-duration-fast: var(--media-duration-instant);
--media-duration: var(--media-duration-instant);
--media-duration-slow: var(--media-duration-instant);
--media-duration-slower: var(--media-duration-instant);
--media-duration-controls: var(--media-duration-instant);
--media-duration-dialog: var(--media-duration-instant);
--media-duration-indicator: var(--media-duration-instant);
--media-duration-menu: 0ms;
--media-duration-slider: 0ms;
--media-delay-dialog: 0ms;
--media-hidden-scale: 1;
--media-hidden-blur: 0px;
--media-hidden-offset: 0px;
--media-hidden-icon-scale: 1;
--media-hidden-indicator-scale: 1;
--media-hidden-indicator-offset: 0px;
--media-hidden-playback-scale: 1;
--media-hidden-popup-scale: 1;
--media-hidden-popup-blur: 0px;
--media-hidden-preview-scale: 1;
--media-hidden-preview-offset: 0px;
--media-hidden-seek-offset: 0px;
--media-popup-translate-distance: 0px;
--media-spinner-animation: none;
--media-icon-airplay-fill-animation: none;
--media-icon-airplay-triangle-animation: none;
}
}
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
.media-skin {
--media-popover: var(--media-background);
--media-scrollbar-thumb-color: color-mix(in oklab, currentColor 80%, transparent);
--media-backdrop-filter-surface: none;
--media-backdrop-filter-indicator: none;
--media-backdrop-filter-dialog: none;
--media-shadow-surface-inset: inset 0 1px 0 0 rgb(255 255 255 / 0.25), inset 0 0 0 1px rgb(255 255 255 / 0.125);
scrollbar-width: auto;
}
@supports (color: oklch(from currentColor l c h)) {
.media-skin {
--media-scrollbar-thumb-color: oklch(from currentColor l c h / 0.8);
}
}
}
@media (forced-colors: active) {
.media-skin {
--media-border: CanvasText;
--media-frame-border: CanvasText;
--media-popover: Canvas;
--media-popover-foreground: CanvasText;
--media-ring: CanvasText;
--media-shadow-surface-inset: inset 0 1px 0 0 CanvasText, inset 0 0 0 1px CanvasText;
}
}
}
@@ -0,0 +1,151 @@
@layer base.theme, base.preset, base.preferences;
@layer base.theme {
/* Default theme tokens. Neutral selectors, preset adaptations, and preferences override these. */
.media-skin {
/* Colors. */
--media-background: oklch(0 0 0);
--media-foreground: oklch(1 0 0);
--media-controls: oklch(0 0 0 / 0.35);
--media-controls-foreground: var(--media-foreground);
--media-popover: oklch(1 0 0 / 0.1);
--media-popover-foreground: var(--media-foreground);
--media-primary: var(--media-accent-color, var(--media-default-accent-color));
/* Browsers without `contrast-color()` use the theme's default text color; see the `@supports` blocks below. */
--media-primary-foreground: var(--media-accent-text-color, var(--media-default-accent-text-color));
--media-accent: var(--media-accent-color, color-mix(in oklab, var(--media-default-accent-color) 10%, transparent));
--media-accent-foreground: var(--media-accent-text-color, currentColor);
--media-muted: oklch(1 0 0 / 0.15);
--media-muted-foreground: oklch(1 0 0 / 0.65);
--media-border: oklch(0 0 0 / 0.1);
--media-ring: oklch(1 0 0);
--media-backdrop: oklch(0 0 0);
--media-live-color: oklch(0.65 0.22 27);
/* Shadows and surfaces. Preferences switch the backdrop filters off and strengthen the inset highlight. */
--media-shadow-surface: 0 1px 3px 0 oklch(0 0 0 / 0.15), 0 1px 2px -1px oklch(0 0 0 / 0.15);
--media-shadow-surface-inset: inset 0 1px 0 0 rgb(255 255 255 / 0.1), inset 0 0 0 1px rgb(255 255 255 / 0.05);
--media-shadow-thumb: 0 0 0 1px rgb(0 0 0 / 0.1), 0 1px 3px 0 rgb(0 0 0 / 0.35), 0 1px 2px -1px rgb(0 0 0 / 0.35);
--media-shadow-tooltip: 0 0 0 1px var(--media-border), var(--media-shadow-surface);
--media-shadow-separator: 0 1px 0 0 oklch(1 0 0 / 0.075);
--media-shadow-current-color: oklch(from currentColor 0 0 0 / clamp(0, calc((l - 0.5) * 0.5), 0.15));
--media-shadow-subtle-current-color: color-mix(in oklab, var(--media-shadow-current-color) 40%, transparent);
--media-text-shadow-dialog: 0 1px 0 rgb(0 0 0 / 0.25);
--media-backdrop-filter-surface: blur(16px) saturate(110%);
--media-backdrop-filter-indicator: blur(8px);
--media-backdrop-filter-dialog: blur(16px) saturate(110%);
/* Controls. */
--media-control-corner-shape: round;
--media-control-radius: 99px;
--media-control-size: calc(var(--media-spacing) * 9);
--media-controls-radius: var(--media-control-radius);
--media-default-accent-color: oklch(1 0 0);
--media-default-accent-text-color: oklch(0 0 0);
/* Motion. Preferences collapse durations and neutralize hidden-state values under reduced motion. */
--media-duration-instant: 50ms;
--media-duration-fast: 100ms;
--media-duration: 150ms;
--media-duration-slow: 200ms;
--media-duration-slower: 250ms;
--media-duration-controls: 100ms;
--media-duration-dialog: 350ms;
--media-duration-indicator: var(--media-duration-slower);
--media-duration-menu: 250ms;
--media-duration-slider: var(--media-duration-fast);
--media-delay-dialog: 100ms;
--media-hidden-scale: 0.95;
--media-hidden-blur: 8px;
--media-hidden-offset: calc(var(--media-spacing) * 1);
--media-hidden-icon-scale: 0;
--media-hidden-indicator-scale: 0.9;
--media-hidden-indicator-offset: -25%;
--media-hidden-playback-scale: 0.85;
--media-hidden-popup-scale: 0.95;
--media-hidden-popup-blur: 4px;
--media-hidden-preview-scale: 0.8;
--media-hidden-preview-offset: calc(var(--media-spacing) * 2);
--media-hidden-seek-offset: 60%;
/* Popups and menus. */
--media-menu-item-radius: calc(var(--media-spacing) * 2);
--media-popover-boundary-offset: calc(var(--media-spacing) * 3);
--media-popover-side-offset: calc(var(--media-spacing) * 3);
--media-popup-radius: calc(var(--media-spacing) * 3);
--media-popup-translate-distance: calc(var(--media-spacing) * 2);
--media-tooltip-boundary-offset: var(--media-popover-boundary-offset);
--media-tooltip-side-offset: var(--media-popover-side-offset);
/* Dialogs. */
--media-dialog-radius: calc(var(--media-spacing) * 7);
--media-dialog-width: calc(100% - var(--media-spacing) * 6);
--media-dialog-max-width: calc(var(--media-spacing) * 72);
/* Sliders. */
--media-slider-preview-label-offset: calc(var(--media-spacing) * 10.5);
/* Player frame and scale. */
--media-video-border-radius: var(--media-border-radius, 28px);
--media-scale: 1;
--media-scrollbar-thumb-color: color-mix(in oklab, currentColor 30%, transparent);
--media-spacing: calc(var(--media-scale-unit, 16px) * var(--media-scale) / 4);
scrollbar-color: var(--media-scrollbar-thumb-color) transparent;
scrollbar-width: thin;
}
/*
* Newer color functions stay behind these checks, and the rule above holds the fallback for browsers without them.
* Relative colors that keep their origin's channels fall back to the same color through `color-mix()`.
*/
@supports (color: contrast-color(red)) {
.media-skin {
--media-primary-foreground: var(--media-accent-text-color, contrast-color(var(--media-primary)));
--media-accent-foreground: var(
--media-accent-text-color,
contrast-color(var(--media-accent-color, var(--media-internal-accent-text-fallback, oklch(0 0 0))))
);
}
}
@supports (color: oklch(from red l c h)) {
.media-skin {
--media-shadow-subtle-current-color: oklch(from var(--media-shadow-current-color) l c h / calc(alpha * 0.4));
}
}
@supports (color: oklch(from currentColor l c h)) {
.media-skin {
--media-scrollbar-thumb-color: oklch(from currentColor l c h / 0.3);
}
}
.media-skin::-webkit-scrollbar-thumb {
background: var(--media-scrollbar-thumb-color);
border-radius: 9999px;
}
.media-skin:fullscreen {
--media-object-fit: contain;
--media-video-border-radius: 0;
}
@media (min-width: 1280px) {
.media-skin:fullscreen {
--media-scale: 1.25;
}
}
@media (min-width: 1536px) {
.media-skin:fullscreen {
--media-scale: 1.5;
}
}
@media (min-width: 1920px) {
.media-skin:fullscreen {
--media-scale: 1.75;
}
}
}
@@ -0,0 +1,3 @@
@import "../base.css";
@import "./captions.css";
@import "./theme.css";
@@ -0,0 +1,37 @@
@layer components {
@scope (.media-skin) {
:scope {
--media-caption-track-duration: var(--media-duration-controls);
--media-caption-track-delay: 25ms;
--media-caption-track-y: calc(var(--media-spacing) * -2);
}
video::-webkit-media-text-track-container {
z-index: 20;
font-family: inherit;
scale: 0.98;
translate: 0 var(--media-caption-track-y);
transition: translate var(--media-caption-track-duration) ease-out;
transition-delay: var(--media-caption-track-delay);
}
:scope[data-controls-visible] {
--media-caption-track-y: var(--media-caption-controls-y);
}
/* Compat combines the offset and scale into one transform for browsers without individual transform properties. */
:scope[data-theme="compat"] video::-webkit-media-text-track-container {
scale: none;
translate: none;
transform: translateY(var(--media-caption-track-y, 0px)) scale(0.98);
transition-property: transform;
}
/* Matches the `--container-media-2xl` layout breakpoint. */
@container media-root (width >= 42rem) {
:scope[data-theme="neutral"][data-controls-visible] video {
--media-caption-track-y: calc(var(--media-spacing) * -12);
}
}
}
}
@@ -0,0 +1,94 @@
@layer base.theme, base.preset, base.preferences;
@layer base.preset {
.media-skin:is([data-preset="video"], [data-preset="live-video"]) {
/* Light border for browsers without `light-dark()`; see the `@supports` block below. */
--media-frame-border: var(--media-border-color, rgb(0 0 0 / 0.1));
/* Scrims for browsers without relative colors or gradient interpolation; the `@supports` blocks below refine them. */
--media-controls-gradient: linear-gradient(
to top,
color-mix(in oklab, var(--media-backdrop) 40%, transparent),
color-mix(in oklab, var(--media-backdrop) 20%, transparent),
color-mix(in oklab, var(--media-backdrop) 50%, transparent)
);
--media-caption-controls-y: calc(var(--media-spacing) * -14);
--media-slider-preview-offset: calc(var(--media-spacing) * 9);
--media-thumbnail-gradient: linear-gradient(
to top,
color-mix(in oklab, var(--media-backdrop) 50%, transparent),
color-mix(in oklab, var(--media-backdrop) 10%, transparent),
transparent
);
}
@supports (color: oklch(from red l c h)) and (background-image: linear-gradient(in oklab, red, red)) {
.media-skin:is([data-preset="video"], [data-preset="live-video"]) {
--media-controls-gradient: linear-gradient(
to top in oklab,
oklch(from var(--media-backdrop) l c h / 0.4),
oklch(from var(--media-backdrop) l c h / 0.2),
oklch(from var(--media-backdrop) l c h / 0.5)
);
}
}
@supports (color: oklch(from red l c h)) {
.media-skin:is([data-preset="video"], [data-preset="live-video"]) {
--media-thumbnail-gradient: linear-gradient(
to top,
oklch(from var(--media-backdrop) l c h / 0.5),
oklch(from var(--media-backdrop) l c h / 0.1),
transparent
);
}
}
.media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] {
--media-frame-border: var(--media-border-color, rgb(0 0 0 / 0.15));
--media-controls-gradient: linear-gradient(
to top,
color-mix(in oklab, var(--media-backdrop) 50%, transparent),
color-mix(in oklab, var(--media-backdrop) 20%, transparent)
);
--media-indicator-gradient: linear-gradient(
to bottom,
color-mix(in oklab, var(--media-backdrop) 35%, transparent),
color-mix(in oklab, var(--media-backdrop) 20%, transparent) calc(var(--media-spacing) * 12),
transparent
);
--media-caption-controls-y: calc(var(--media-spacing) * -18);
--media-slider-preview-offset: calc(var(--media-spacing) * 11);
}
@supports (color: oklch(from red l c h)) and (background-image: linear-gradient(in oklab, red, red)) {
.media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] {
--media-controls-gradient: linear-gradient(
to top in oklab,
oklch(from var(--media-backdrop) l c h / 0.5),
oklch(from var(--media-backdrop) l c h / 0.2)
);
}
}
@supports (color: oklch(from red l c h)) {
.media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] {
--media-indicator-gradient: linear-gradient(
to bottom,
oklch(from var(--media-backdrop) l c h / 0.35),
oklch(from var(--media-backdrop) l c h / 0.2) calc(var(--media-spacing) * 12),
transparent
);
}
}
/* Keep `light-dark()` behind this check so a build that lowers it cannot rewrite it; see `audio/theme.css`. */
@supports (color: light-dark(red, red)) {
.media-skin:is([data-preset="video"], [data-preset="live-video"]) {
--media-frame-border: var(--media-border-color, light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.15)));
}
.media-skin:is([data-preset="video"], [data-preset="live-video"])[data-theme="neutral"] {
--media-frame-border: var(--media-border-color, light-dark(rgb(0 0 0 / 0.15), rgb(255 255 255 / 0.15)));
}
}
}
@@ -0,0 +1,51 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { AirPlayButton as AirPlayButtonPrimitive } from '@videojs/react';
import {
AirPlayEnterIcon as AirPlayEnterIconPrimitive,
AirPlayExitIcon as AirPlayExitIconPrimitive,
} from '@videojs/react/icons';
import { Button } from '@/components/videojs/ui/button';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type AirPlayButtonProps = Omit<AirPlayButtonPrimitive.Props, 'children'>;
export function AirPlayButton({ className, ...props }: AirPlayButtonProps = {}) {
return (
<AirPlayButtonPrimitive
render={<Button />}
className={(state) =>
cn(
'group/airplay',
'not-data-[airplay-state=connected]:[--media-icon-airplay-fill-animation:none]',
'not-data-[airplay-state=connected]:[--media-icon-airplay-triangle-animation:none]',
resolveClassName(className, state)
)
}
{...props}
>
<AirPlayEnterIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-not-data-[airplay-state=connected]/airplay:scale-100',
'group-not-data-[airplay-state=connected]/airplay:opacity-100'
)}
/>
<AirPlayExitIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-data-[airplay-state=connected]/airplay:scale-100',
'group-data-[airplay-state=connected]/airplay:opacity-100'
)}
/>
</AirPlayButtonPrimitive>
);
}
@@ -0,0 +1,115 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { audioText } from '@videojs/core/i18n/text/menu';
import { Menu } from '@videojs/react';
import { Text as TextPrimitive } from '@videojs/react';
import { SpeechIcon as SpeechIconPrimitive } from '@videojs/react/icons';
import { AudioTrackRadioGroup } from '@videojs/react/ui/audio-track-radio-group';
import { MenuChevron } from '@/components/videojs/ui/menu-chevron';
import { RadioItem } from '@/components/videojs/ui/radio-item';
import { cn } from '@/lib/utils';
export type AudioTrackMenuProps = Omit<Menu.RootProps, 'children'>;
export function AudioTrackMenu({ ...props }: AudioTrackMenuProps = {}) {
return (
<Menu.Root {...props}>
<AudioTrackRadioGroup.Root>
<Menu.Trigger
className={cn(
'group/menu-trigger-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'justify-between tabular-nums text-inherit',
'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden',
'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
'media-anchored:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]',
'media-anchored:media-highlighted:bg-transparent'
)}
>
<SpeechIconPrimitive
className={cn(
'shrink-0 drop-shadow-media-icon text-media-muted-foreground',
'size-media-icon',
'group-media-highlighted/menu-trigger-item:text-inherit'
)}
/>
<TextPrimitive token={audioText.key}>{audioText.text}</TextPrimitive>
<span className={'ms-auto inline-flex min-w-0 items-center gap-1 ps-2'}>
<AudioTrackRadioGroup.Value className={'max-w-24 truncate opacity-65'} />
<MenuChevron />
</span>
</Menu.Trigger>
<Menu.Content
className={cn(
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0',
'not-data-submenu:flex not-data-submenu:flex-col not-data-submenu:gap-0.5',
'not-data-submenu:data-child-open:blur-media-hidden',
'not-data-submenu:data-child-open:before:hidden',
'data-submenu:media-transitioning:pointer-events-none data-submenu:media-transitioning:overflow-hidden',
'data-submenu:media-transitioning:blur-media-hidden',
'absolute max-h-[inherit] overflow-auto overscroll-none outline-hidden',
'transition-[translate,filter] duration-media-menu ease-out',
'not-data-submenu:inset-x-1 not-data-submenu:top-1',
'not-data-submenu:data-child-open:-translate-x-full',
'not-data-submenu:data-child-open:rtl:translate-x-full',
'data-submenu:inset-x-0 data-submenu:top-0 data-submenu:z-10 data-submenu:max-h-[inherit] data-submenu:p-1',
'data-submenu:media-transitioning:translate-x-full data-submenu:media-transitioning:rtl:-translate-x-full'
)}
>
<Menu.Item
className={cn(
'group/menu-back-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'mb-0.5 w-full'
)}
>
<MenuChevron back />
<TextPrimitive token={audioText.key}>{audioText.text}</TextPrimitive>
</Menu.Item>
<Menu.Separator
className={cn(
'my-1 block border-b border-media-border media-high-contrast:border-media-foreground/25',
'shadow-media-separator'
)}
/>
<AudioTrackRadioGroup.Options
className={cn(
'flex [max-height:inherit] flex-col gap-0.5',
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0'
)}
renderItem={(props, item) => (
<RadioItem {...props}>
<span>{item.label}</span>
</RadioItem>
)}
></AudioTrackRadioGroup.Options>
</Menu.Content>
</AudioTrackRadioGroup.Root>
</Menu.Root>
);
}
@@ -0,0 +1,31 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { BufferingIndicator as BufferingIndicatorPrimitive } from '@videojs/react';
import { SpinnerIcon as SpinnerIconPrimitive } from '@videojs/react/icons';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type BufferingIndicatorProps = Omit<BufferingIndicatorPrimitive.Props, 'children'>;
export function BufferingIndicator({ className, ...props }: BufferingIndicatorProps = {}) {
return (
<BufferingIndicatorPrimitive
className={(state) =>
cn(
'pointer-events-none absolute inset-0 hidden place-content-center text-media-controls-foreground',
'before:absolute before:inset-0 before:bg-media-backdrop/35 before:backdrop-filter-media-indicator',
'not-data-visible:[--media-spinner-animation:none] data-visible:grid',
resolveClassName(className, state)
)
}
{...props}
>
<SpinnerIconPrimitive className={'relative z-30 size-media-icon drop-shadow-media-icon'} />
</BufferingIndicatorPrimitive>
);
}
@@ -0,0 +1,57 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { Tooltip } from '@videojs/react';
import type { ReactElement, ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface ButtonTooltipProps extends Omit<Tooltip.RootProps, 'children'> {
children: ReactElement;
label?: ReactNode;
}
export function ButtonTooltip({ children, label, ...props }: ButtonTooltipProps) {
return (
<Tooltip.Root {...props}>
<Tooltip.Trigger render={children} />
<Tooltip.Popup
className={cn(
'm-0 overflow-visible border-0 text-inherit',
'media-transitioning:opacity-0 media-transitioning:blur-media-hidden-popup media-transitioning:scale-media-hidden-popup',
'data-starting-style:transform-[translate(var(--media-popup-translate-x-distance,0),var(--media-popup-translate-y-distance,0))]',
'data-ending-style:transform-none',
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left',
'data-[side=top]:[--media-popup-translate-y-distance:var(--media-popup-translate-distance)]',
'data-[side=bottom]:[--media-popup-translate-y-distance:calc(var(--media-popup-translate-distance)*-1)]',
'data-[side=left]:[--media-popup-translate-x-distance:var(--media-popup-translate-distance)]',
'data-[side=right]:[--media-popup-translate-x-distance:calc(var(--media-popup-translate-distance)*-1)]',
'before:pointer-events-auto before:absolute',
'data-[side=top]:before:inset-x-0 data-[side=top]:before:top-full',
'data-[side=bottom]:before:inset-x-0 data-[side=bottom]:before:bottom-full',
'data-[side=left]:before:inset-y-0 data-[side=left]:before:left-full',
'data-[side=right]:before:inset-y-0 data-[side=right]:before:right-full',
'data-[side=top]:before:h-(--media-popup-side-offset) data-[side=bottom]:before:h-(--media-popup-side-offset)',
'data-[side=left]:before:w-(--media-popup-side-offset) data-[side=right]:before:w-(--media-popup-side-offset)',
'transition-media-popup data-ending-style:duration-media-instant',
'bg-media-popover text-media-popover-foreground surface-media after:surface-media-inset',
'whitespace-nowrap rounded-media-control py-1 text-media [--media-popup-side-offset:var(--media-tooltip-side-offset)]',
'data-open:flex data-open:items-center data-open:gap-1',
'px-2.5'
)}
>
{label ?? <Tooltip.Label />}
{!label && (
<Tooltip.Shortcut
className={
'min-w-[1.5em] rounded-[--spacing(1)] bg-media-muted p-[0.1em] text-center text-media-sm [font-family:inherit] font-semibold leading-tight'
}
/>
)}
</Tooltip.Popup>
</Tooltip.Root>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
/** Shared button carrying the base interactive styles used by media controls. */
export type ButtonProps = ComponentProps<'button'>;
export function Button({ className, ...props }: ButtonProps) {
return (
<button
className={cn(
'grid size-media-control min-h-0 shrink-0 touch-manipulation select-none place-items-center rounded-media-control border-0 bg-transparent p-0 text-center text-inherit [corner-shape:var(--media-control-corner-shape)]',
'cursor-pointer focus-ring-media',
'will-change-[scale] duration-media-base ease-out [transition-property:background-color,color,outline-offset,scale]',
'media-highlighted:highlight-media',
'not-aria-disabled:hover:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'not-aria-disabled:active:scale-[0.97]',
'motion-reduce:scale-100 motion-reduce:will-change-auto motion-reduce:[transition-property:background-color,color]',
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
className
)}
{...props}
/>
);
}
@@ -0,0 +1,42 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { CaptionsButton as CaptionsButtonPrimitive } from '@videojs/react';
import {
CaptionsOffIcon as CaptionsOffIconPrimitive,
CaptionsOnIcon as CaptionsOnIconPrimitive,
} from '@videojs/react/icons';
import { Button } from '@/components/videojs/ui/button';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type CaptionsButtonProps = Omit<CaptionsButtonPrimitive.Props, 'children'>;
export function CaptionsButton({ className, ...props }: CaptionsButtonProps = {}) {
return (
<CaptionsButtonPrimitive
render={<Button />}
className={(state) => cn('group/captions', resolveClassName(className, state))}
{...props}
>
<CaptionsOffIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-not-data-active/captions:scale-100 group-not-data-active/captions:opacity-100'
)}
/>
<CaptionsOnIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-data-active/captions:scale-100 group-data-active/captions:opacity-100'
)}
/>
</CaptionsButtonPrimitive>
);
}
@@ -0,0 +1,115 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { captionsText } from '@videojs/core/i18n/text/menu';
import { Menu } from '@videojs/react';
import { Text as TextPrimitive } from '@videojs/react';
import { CaptionsOffIcon as CaptionsOffIconPrimitive } from '@videojs/react/icons';
import { CaptionsRadioGroup } from '@videojs/react/ui/captions-radio-group';
import { MenuChevron } from '@/components/videojs/ui/menu-chevron';
import { RadioItem } from '@/components/videojs/ui/radio-item';
import { cn } from '@/lib/utils';
export type CaptionsSubmenuProps = Omit<Menu.RootProps, 'children'>;
export function CaptionsSubmenu({ ...props }: CaptionsSubmenuProps = {}) {
return (
<Menu.Root {...props}>
<CaptionsRadioGroup.Root>
<Menu.Trigger
className={cn(
'group/menu-trigger-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'justify-between tabular-nums text-inherit',
'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden',
'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
'media-anchored:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]',
'media-anchored:media-highlighted:bg-transparent'
)}
>
<CaptionsOffIconPrimitive
className={cn(
'shrink-0 drop-shadow-media-icon text-media-muted-foreground',
'size-media-icon',
'group-media-highlighted/menu-trigger-item:text-inherit'
)}
/>
<TextPrimitive token={captionsText.key}>{captionsText.text}</TextPrimitive>
<span className={'ms-auto inline-flex min-w-0 items-center gap-1 ps-2'}>
<CaptionsRadioGroup.Value className={'max-w-24 truncate opacity-65'} />
<MenuChevron />
</span>
</Menu.Trigger>
<Menu.Content
className={cn(
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0',
'not-data-submenu:flex not-data-submenu:flex-col not-data-submenu:gap-0.5',
'not-data-submenu:data-child-open:blur-media-hidden',
'not-data-submenu:data-child-open:before:hidden',
'data-submenu:media-transitioning:pointer-events-none data-submenu:media-transitioning:overflow-hidden',
'data-submenu:media-transitioning:blur-media-hidden',
'absolute max-h-[inherit] overflow-auto overscroll-none outline-hidden',
'transition-[translate,filter] duration-media-menu ease-out',
'not-data-submenu:inset-x-1 not-data-submenu:top-1',
'not-data-submenu:data-child-open:-translate-x-full',
'not-data-submenu:data-child-open:rtl:translate-x-full',
'data-submenu:inset-x-0 data-submenu:top-0 data-submenu:z-10 data-submenu:max-h-[inherit] data-submenu:p-1',
'data-submenu:media-transitioning:translate-x-full data-submenu:media-transitioning:rtl:-translate-x-full'
)}
>
<Menu.Item
className={cn(
'group/menu-back-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'mb-0.5 w-full'
)}
>
<MenuChevron back />
<TextPrimitive token={captionsText.key}>{captionsText.text}</TextPrimitive>
</Menu.Item>
<Menu.Separator
className={cn(
'my-1 block border-b border-media-border media-high-contrast:border-media-foreground/25',
'shadow-media-separator'
)}
/>
<CaptionsRadioGroup.Options
className={cn(
'flex [max-height:inherit] flex-col gap-0.5',
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0'
)}
renderItem={(props, item) => (
<RadioItem {...props}>
<span>{item.label}</span>
</RadioItem>
)}
></CaptionsRadioGroup.Options>
</Menu.Content>
</CaptionsRadioGroup.Root>
</Menu.Root>
);
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { CastButton as CastButtonPrimitive } from '@videojs/react';
import { CastEnterIcon as CastEnterIconPrimitive, CastExitIcon as CastExitIconPrimitive } from '@videojs/react/icons';
import { Button } from '@/components/videojs/ui/button';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type CastButtonProps = Omit<CastButtonPrimitive.Props, 'children'>;
export function CastButton({ className, ...props }: CastButtonProps = {}) {
return (
<CastButtonPrimitive
render={<Button />}
className={(state) => cn('group/cast', resolveClassName(className, state))}
{...props}
>
<CastEnterIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-not-data-[cast-state=connected]/cast:scale-100',
'group-not-data-[cast-state=connected]/cast:opacity-100'
)}
/>
<CastExitIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-data-[cast-state=connected]/cast:scale-100',
'group-data-[cast-state=connected]/cast:opacity-100'
)}
/>
</CastButtonPrimitive>
);
}
+33
View File
@@ -0,0 +1,33 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { Container as ContainerPrimitive } from '@videojs/react';
import { cn } from '@/lib/utils';
export interface ContainerProps extends Omit<ContainerPrimitive.Props, 'children'> {
children?: ContainerPrimitive.Props['children'];
}
export function Container({ children, className, ...props }: ContainerProps) {
return (
<ContainerPrimitive
className={cn(
'media-skin',
'relative isolate block h-full w-full overflow-clip rounded-media-player bg-media-background @container/media-root',
'[--spacing:var(--media-spacing)] font-media text-media leading-normal subpixel-antialiased',
'outline-2 -outline-offset-4 outline-transparent transition-[outline-offset,outline-color] duration-media-fast ease-out',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit]',
'after:border after:border-(--media-frame-border) [&:fullscreen]:after:hidden',
className
)}
{...props}
>
{children}
</ContainerPrimitive>
);
}
@@ -0,0 +1,48 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { ErrorDialog as ErrorDialogPrimitive } from '@videojs/react';
import { Button } from '@/components/videojs/ui/button';
import { cn } from '@/lib/utils';
export function ErrorDialog() {
return (
<ErrorDialogPrimitive.Root>
<ErrorDialogPrimitive.Backdrop
className={cn(
'absolute inset-0 z-40 bg-media-backdrop/20 opacity-100 backdrop-filter-media-dialog rounded-[inherit]',
'not-data-open:hidden transition-opacity delay-media-dialog duration-media-dialog ease-out',
'media-transitioning:opacity-0 data-ending-style:delay-0',
'data-ending-style:duration-media-slower'
)}
/>
<ErrorDialogPrimitive.Popup
className={cn(
'absolute top-1/2 left-1/2 z-50 flex w-media-dialog-width max-w-media-dialog max-h-[calc(100%-0.5rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-3 rounded-media-dialog text-media-popover-foreground outline-hidden not-data-open:hidden',
'text-shadow-media-dialog transition-[opacity,scale] delay-media-dialog duration-media-dialog ease-out',
'media-transitioning:scale-media-hidden-popup media-transitioning:opacity-0 data-ending-style:delay-0',
'bg-media-popover surface-media after:surface-media-inset',
'p-3',
'data-ending-style:duration-media-slower'
)}
>
<div className={cn('flex min-h-0 flex-col gap-2 overflow-y-auto', 'px-2 pt-2 pb-1.5')}>
<ErrorDialogPrimitive.Title className={'m-0 text-media-lg font-semibold leading-tight'} />
<ErrorDialogPrimitive.Description className={'m-0 opacity-70 wrap-anywhere'} />
</div>
<div className={'flex shrink-0 gap-2'}>
<ErrorDialogPrimitive.Close
render={<Button />}
className={
'h-media-control w-full flex-1 bg-media-primary! px-4 py-2 font-medium text-media-primary-foreground!'
}
/>
</div>
</ErrorDialogPrimitive.Popup>
</ErrorDialogPrimitive.Root>
);
}
@@ -0,0 +1,42 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { FullscreenButton as FullscreenButtonPrimitive } from '@videojs/react';
import {
FullscreenEnterIcon as FullscreenEnterIconPrimitive,
FullscreenExitIcon as FullscreenExitIconPrimitive,
} from '@videojs/react/icons';
import { Button } from '@/components/videojs/ui/button';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type FullscreenButtonProps = Omit<FullscreenButtonPrimitive.Props, 'children'>;
export function FullscreenButton({ className, ...props }: FullscreenButtonProps = {}) {
return (
<FullscreenButtonPrimitive
render={<Button />}
className={(state) => cn('group/fullscreen', resolveClassName(className, state))}
{...props}
>
<FullscreenEnterIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-not-data-fullscreen/fullscreen:scale-100 group-not-data-fullscreen/fullscreen:opacity-100'
)}
/>
<FullscreenExitIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-data-fullscreen/fullscreen:scale-100 group-data-fullscreen/fullscreen:opacity-100'
)}
/>
</FullscreenButtonPrimitive>
);
}
@@ -0,0 +1,22 @@
import { ChevronIcon as ChevronIconPrimitive } from '@videojs/react/icons';
import type { ClassValue } from 'cn';
import { cn } from '@/lib/utils';
export interface MenuChevronProps {
back?: boolean;
className?: ClassValue;
}
export function MenuChevron({ back = false, className }: MenuChevronProps = {}) {
return (
<ChevronIconPrimitive
className={cn(
back
? 'shrink-0 drop-shadow-media-icon text-media-muted-foreground size-media-icon-sm rotate-180 rtl:rotate-0 rtl:scale-[1_1] group-media-highlighted/menu-back-item:text-inherit'
: 'shrink-0 drop-shadow-media-icon text-media-muted-foreground size-media-icon-sm rtl:scale-[-1_1] group-media-highlighted/menu-trigger-item:text-inherit',
className
)}
/>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { MuteButton as MuteButtonPrimitive } from '@videojs/react';
import {
VolumeOffIcon as VolumeOffIconPrimitive,
VolumeLowIcon as VolumeLowIconPrimitive,
VolumeHighIcon as VolumeHighIconPrimitive,
} from '@videojs/react/icons';
import { Button } from '@/components/videojs/ui/button';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type MuteButtonProps = Omit<MuteButtonPrimitive.Props, 'children'>;
export function MuteButton({ className, ...props }: MuteButtonProps = {}) {
return (
<MuteButtonPrimitive
render={<Button />}
className={(state) => cn('group/mute', resolveClassName(className, state))}
{...props}
>
<VolumeOffIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-data-muted/mute:scale-100 group-data-muted/mute:opacity-100'
)}
/>
<VolumeLowIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0',
'group-not-data-muted/mute:group-data-[volume-level=low]/mute:opacity-100',
'group-not-data-muted/mute:group-data-[volume-level=low]/mute:scale-100'
)}
/>
<VolumeHighIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0',
'group-not-data-muted/mute:group-not-data-[volume-level=low]/mute:opacity-100',
'group-not-data-muted/mute:group-not-data-[volume-level=low]/mute:scale-100'
)}
/>
</MuteButtonPrimitive>
);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { PiPButton as PiPButtonPrimitive } from '@videojs/react';
import { PipEnterIcon as PipEnterIconPrimitive, PipExitIcon as PipExitIconPrimitive } from '@videojs/react/icons';
import { Button } from '@/components/videojs/ui/button';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type PiPButtonProps = Omit<PiPButtonPrimitive.Props, 'children'>;
export function PiPButton({ className, ...props }: PiPButtonProps = {}) {
return (
<PiPButtonPrimitive
render={<Button />}
className={(state) => cn('group/pip', resolveClassName(className, state))}
{...props}
>
<PipEnterIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-not-data-pip/pip:scale-100 group-not-data-pip/pip:opacity-100'
)}
/>
<PipExitIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'opacity-0 group-data-pip/pip:scale-100 group-data-pip/pip:opacity-100'
)}
/>
</PiPButtonPrimitive>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { PlayButton as PlayButtonPrimitive } from '@videojs/react';
import {
RestartIcon as RestartIconPrimitive,
PlayIcon as PlayIconPrimitive,
PauseIcon as PauseIconPrimitive,
} from '@videojs/react/icons';
import { Button } from '@/components/videojs/ui/button';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type PlayButtonProps = Omit<PlayButtonPrimitive.Props, 'children'>;
export function PlayButton({ className, ...props }: PlayButtonProps = {}) {
return (
<PlayButtonPrimitive
render={<Button />}
className={(state) => cn('group/play', resolveClassName(className, state))}
{...props}
>
<RestartIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'scale-media-hidden-icon opacity-0 group-data-ended/play:scale-100 group-data-ended/play:opacity-100'
)}
/>
<PlayIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'scale-media-hidden-icon opacity-0',
'group-not-data-ended/play:group-data-paused/play:opacity-100',
'group-not-data-ended/play:group-data-paused/play:scale-100',
'group-not-data-ended/play:group-not-data-started/play:opacity-100',
'group-not-data-ended/play:group-not-data-started/play:scale-100'
)}
/>
<PauseIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-[opacity,scale] duration-media-base ease-out',
'scale-media-hidden-icon opacity-0',
'group-data-started/play:group-not-data-paused/play:group-not-data-ended/play:opacity-100',
'group-data-started/play:group-not-data-paused/play:group-not-data-ended/play:scale-100'
)}
/>
</PlayButtonPrimitive>
);
}
@@ -0,0 +1,115 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { speedText } from '@videojs/core/i18n/text/menu';
import { Menu } from '@videojs/react';
import { Text as TextPrimitive } from '@videojs/react';
import { SpeedIcon as SpeedIconPrimitive } from '@videojs/react/icons';
import { PlaybackRateRadioGroup } from '@videojs/react/ui/playback-rate-radio-group';
import { MenuChevron } from '@/components/videojs/ui/menu-chevron';
import { RadioItem } from '@/components/videojs/ui/radio-item';
import { cn } from '@/lib/utils';
export type PlaybackRateSubmenuProps = Omit<Menu.RootProps, 'children'>;
export function PlaybackRateSubmenu({ ...props }: PlaybackRateSubmenuProps = {}) {
return (
<Menu.Root {...props}>
<PlaybackRateRadioGroup.Root>
<Menu.Trigger
className={cn(
'group/menu-trigger-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'justify-between tabular-nums text-inherit',
'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden',
'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
'media-anchored:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]',
'media-anchored:media-highlighted:bg-transparent'
)}
>
<SpeedIconPrimitive
className={cn(
'shrink-0 drop-shadow-media-icon text-media-muted-foreground',
'size-media-icon',
'group-media-highlighted/menu-trigger-item:text-inherit'
)}
/>
<TextPrimitive token={speedText.key}>{speedText.text}</TextPrimitive>
<span className={'ms-auto inline-flex min-w-0 items-center gap-1 ps-2'}>
<PlaybackRateRadioGroup.Value className={'max-w-24 truncate opacity-65'} />
<MenuChevron />
</span>
</Menu.Trigger>
<Menu.Content
className={cn(
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0',
'not-data-submenu:flex not-data-submenu:flex-col not-data-submenu:gap-0.5',
'not-data-submenu:data-child-open:blur-media-hidden',
'not-data-submenu:data-child-open:before:hidden',
'data-submenu:media-transitioning:pointer-events-none data-submenu:media-transitioning:overflow-hidden',
'data-submenu:media-transitioning:blur-media-hidden',
'absolute max-h-[inherit] overflow-auto overscroll-none outline-hidden',
'transition-[translate,filter] duration-media-menu ease-out',
'not-data-submenu:inset-x-1 not-data-submenu:top-1',
'not-data-submenu:data-child-open:-translate-x-full',
'not-data-submenu:data-child-open:rtl:translate-x-full',
'data-submenu:inset-x-0 data-submenu:top-0 data-submenu:z-10 data-submenu:max-h-[inherit] data-submenu:p-1',
'data-submenu:media-transitioning:translate-x-full data-submenu:media-transitioning:rtl:-translate-x-full'
)}
>
<Menu.Item
className={cn(
'group/menu-back-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'mb-0.5 w-full'
)}
>
<MenuChevron back />
<TextPrimitive token={speedText.key}>{speedText.text}</TextPrimitive>
</Menu.Item>
<Menu.Separator
className={cn(
'my-1 block border-b border-media-border media-high-contrast:border-media-foreground/25',
'shadow-media-separator'
)}
/>
<PlaybackRateRadioGroup.Options
className={cn(
'flex [max-height:inherit] flex-col gap-0.5',
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0'
)}
renderItem={(props, item) => (
<RadioItem {...props}>
<span>{item.label}</span>
</RadioItem>
)}
></PlaybackRateRadioGroup.Options>
</Menu.Content>
</PlaybackRateRadioGroup.Root>
</Menu.Root>
);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { Poster as PosterPrimitive } from '@videojs/react';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export interface PosterProps extends Omit<PosterPrimitive.ImageProps, 'children' | 'render'> {
/** Draws the poster image in place of the one the skin renders. */
renderImage?: PosterPrimitive.ImageProps['render'];
children?: PosterPrimitive.RootProps['children'];
}
export function Poster({ children, className, renderImage, ...props }: PosterProps = {}) {
return (
<PosterPrimitive.Root
className={(state) =>
cn(
'pointer-events-none layer-media',
'transition-opacity duration-media-slower not-data-visible:opacity-0',
resolveClassName(className, state)
)
}
>
<PosterPrimitive.Image
render={renderImage}
className={cn('layer-media object-media', '[&:not([src]):not([srcset])]:invisible')}
{...props}
/>
{children}
</PosterPrimitive.Root>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { qualityText } from '@videojs/core/i18n/text/menu';
import { Menu } from '@videojs/react';
import { Text as TextPrimitive } from '@videojs/react';
import { SwitchesIcon as SwitchesIconPrimitive } from '@videojs/react/icons';
import { QualityRadioGroup } from '@videojs/react/ui/quality-radio-group';
import { MenuChevron } from '@/components/videojs/ui/menu-chevron';
import { RadioItem } from '@/components/videojs/ui/radio-item';
import { cn } from '@/lib/utils';
export type QualityMenuProps = Omit<Menu.RootProps, 'children'>;
export function QualityMenu({ ...props }: QualityMenuProps = {}) {
return (
<Menu.Root {...props}>
<QualityRadioGroup.Root>
<Menu.Trigger
className={cn(
'group/menu-trigger-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'justify-between tabular-nums text-inherit',
'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden',
'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
'media-anchored:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]',
'media-anchored:media-highlighted:bg-transparent'
)}
>
<SwitchesIconPrimitive
className={cn(
'shrink-0 drop-shadow-media-icon text-media-muted-foreground',
'size-media-icon',
'group-media-highlighted/menu-trigger-item:text-inherit'
)}
/>
<TextPrimitive token={qualityText.key}>{qualityText.text}</TextPrimitive>
<span className={'ms-auto inline-flex min-w-0 items-center gap-1 ps-2'}>
<QualityRadioGroup.Value className={'max-w-24 truncate opacity-65'} />
<MenuChevron />
</span>
</Menu.Trigger>
<Menu.Content
className={cn(
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0',
'not-data-submenu:flex not-data-submenu:flex-col not-data-submenu:gap-0.5',
'not-data-submenu:data-child-open:blur-media-hidden',
'not-data-submenu:data-child-open:before:hidden',
'data-submenu:media-transitioning:pointer-events-none data-submenu:media-transitioning:overflow-hidden',
'data-submenu:media-transitioning:blur-media-hidden',
'absolute max-h-[inherit] overflow-auto overscroll-none outline-hidden',
'transition-[translate,filter] duration-media-menu ease-out',
'not-data-submenu:inset-x-1 not-data-submenu:top-1',
'not-data-submenu:data-child-open:-translate-x-full',
'not-data-submenu:data-child-open:rtl:translate-x-full',
'data-submenu:inset-x-0 data-submenu:top-0 data-submenu:z-10 data-submenu:max-h-[inherit] data-submenu:p-1',
'data-submenu:media-transitioning:translate-x-full data-submenu:media-transitioning:rtl:-translate-x-full'
)}
>
<Menu.Item
className={cn(
'group/menu-back-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'mb-0.5 w-full'
)}
>
<MenuChevron back />
<TextPrimitive token={qualityText.key}>{qualityText.text}</TextPrimitive>
</Menu.Item>
<Menu.Separator
className={cn(
'my-1 block border-b border-media-border media-high-contrast:border-media-foreground/25',
'shadow-media-separator'
)}
/>
<QualityRadioGroup.Options
className={cn(
'flex [max-height:inherit] flex-col gap-0.5',
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0'
)}
renderItem={(props, item) => (
<RadioItem {...props}>
<span>
<span>{item.label}</span>
{item.tier ? (
<sup className={'ps-0.5 pt-px text-media-xs font-semibold leading-none opacity-70'}>
{item.tier}
</sup>
) : null}
</span>
{item.badge ? (
<span className={'rounded-media-control bg-media-accent px-1.5 text-media-xs font-semibold'}>
{item.badge}
</span>
) : null}
</RadioItem>
)}
></QualityRadioGroup.Options>
</Menu.Content>
</QualityRadioGroup.Root>
</Menu.Root>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { Menu } from '@videojs/react';
import { CheckIcon as CheckIconPrimitive } from '@videojs/react/icons';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export interface RadioItemProps extends Omit<Menu.RadioItemProps, 'children'> {
children?: Menu.RadioItemProps['children'];
}
export function RadioItem({ children, className, ...props }: RadioItemProps) {
return (
<Menu.RadioItem
className={(state) =>
cn(
'group/menu-radio-item',
'relative flex cursor-pointer select-none items-center gap-1.5 rounded-media-menu-item px-2 py-1.5 text-start whitespace-nowrap',
'focus-ring-media',
'media-highlighted:highlight-media',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'text-shadow-media',
'transition-[background-color,color] duration-media-fast ease-in-out',
'media-anchored:duration-media-instant media-anchored:media-highlighted:duration-media-slow',
'justify-between tabular-nums text-inherit',
'data-[availability=unavailable]:hidden data-[availability=unsupported]:hidden',
'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
'media-anchored:data-highlighted:[anchor-name:--media-menu-item-highlight-anchor]',
'media-anchored:media-highlighted:bg-transparent',
resolveClassName(className, state)
)
}
{...props}
>
{children}
<Menu.ItemIndicator
forceMount
className={'ms-auto -me-1 shrink-0 opacity-0 group-aria-checked/menu-radio-item:opacity-100'}
>
<CheckIconPrimitive
className={cn(
'shrink-0 drop-shadow-media-icon text-media-muted-foreground',
'size-media-icon',
'group-media-highlighted/menu-radio-item:text-inherit'
)}
/>
</Menu.ItemIndicator>
</Menu.RadioItem>
);
}
@@ -0,0 +1,42 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { SeekIndicator as SeekIndicatorPrimitive } from '@videojs/react';
import { ChevronIcon as ChevronIconPrimitive } from '@videojs/react/icons';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type SeekIndicatorProps = Omit<SeekIndicatorPrimitive.RootProps, 'children'>;
export function SeekIndicator({ className, ...props }: SeekIndicatorProps = {}) {
return (
<SeekIndicatorPrimitive.Root
className={(state) =>
cn(
'group/seek-status col-start-2 row-start-1 grid place-content-center gap-1 p-4 text-center',
'media-2xl:p-6',
'data-[direction=backward]:col-start-1 data-[direction=backward]:justify-self-start',
'data-[direction=forward]:col-start-3 data-[direction=forward]:justify-self-end',
resolveClassName(className, state)
)
}
{...props}
>
<ChevronIconPrimitive
className={cn(
'hidden size-media-icon-lg group-data-direction/seek-status:block',
'group-data-[direction=backward]/seek-status:scale-[-1_1]',
'transition-[translate,opacity] duration-media-slow ease-in-out',
'group-media-transitioning/seek-status:opacity-0',
'group-data-[direction=forward]/seek-status:group-data-starting-style/seek-status:-translate-x-media-hidden-seek-offset',
'group-data-[direction=backward]/seek-status:group-data-starting-style/seek-status:translate-x-media-hidden-seek-offset'
)}
/>
<SeekIndicatorPrimitive.Value className={'tabular-nums'} />
</SeekIndicatorPrimitive.Root>
);
}
@@ -0,0 +1,90 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { settingsText } from '@videojs/core/i18n/text/menu';
import { Menu } from '@videojs/react';
import { Text as TextPrimitive } from '@videojs/react';
import { GearIcon as GearIconPrimitive } from '@videojs/react/icons';
import type { ClassValue } from 'cn';
import { Button } from '@/components/videojs/ui/button';
import { ButtonTooltip } from '@/components/videojs/ui/button-tooltip';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export interface SettingsMenuProps extends Omit<Menu.RootProps, 'children'> {
className?: ClassValue;
children?: Menu.ContentProps['children'];
}
export function SettingsMenu({ children, className, ...props }: SettingsMenuProps) {
return (
<Menu.Root side="top" align="center" {...props}>
<ButtonTooltip label={<TextPrimitive token={settingsText.key}>{settingsText.text}</TextPrimitive>} side="top">
<Menu.Trigger
render={<Button />}
className={(state) => cn('group/settings', resolveClassName(className, state))}
>
<GearIconPrimitive
className={cn(
'col-start-1 row-start-1 size-media-icon drop-shadow-media-icon [text-shadow:inherit]',
'transition-transform duration-media-base ease-in-out motion-reduce:transition-none',
'group-aria-expanded/settings:rotate-90'
)}
/>
<TextPrimitive className={'sr-only'} token={settingsText.key}>
{settingsText.text}
</TextPrimitive>
</Menu.Trigger>
</ButtonTooltip>
<Menu.Popup
keepMounted
className={cn(
'm-0 overflow-visible border-0 text-inherit',
'media-transitioning:opacity-0 media-transitioning:blur-media-hidden-popup media-transitioning:scale-media-hidden-popup',
'data-starting-style:transform-[translate(var(--media-popup-translate-x-distance,0),var(--media-popup-translate-y-distance,0))]',
'data-ending-style:transform-none',
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left',
'data-[side=top]:[--media-popup-translate-y-distance:var(--media-popup-translate-distance)]',
'data-[side=bottom]:[--media-popup-translate-y-distance:calc(var(--media-popup-translate-distance)*-1)]',
'data-[side=left]:[--media-popup-translate-x-distance:var(--media-popup-translate-distance)]',
'data-[side=right]:[--media-popup-translate-x-distance:calc(var(--media-popup-translate-distance)*-1)]',
'bg-media-popover text-media-popover-foreground surface-media after:surface-media-inset',
'm-0 min-w-44 max-w-(--media-menu-available-width) overflow-hidden! rounded-media-popup border-0 p-1 [--media-popup-side-offset:var(--media-popover-side-offset)]',
'max-h-[min(var(--media-menu-available-height,--spacing(56)),--spacing(56))] overscroll-none',
'h-(--media-menu-height) w-(--media-menu-width)',
'transition-media-popup media-transitioning:transition-media-popup',
'transition-media-menu-resize'
)}
>
<Menu.Content
className={cn(
'[anchor-scope:--media-menu-item-highlight-anchor]',
'media-anchored:before:anchor-media-highlight',
'media-transitioning:before:hidden',
'media-transitioning:[&_*]:before:hidden',
'media-anchored:has-data-[highlighted=]:before:duration-0',
'not-data-submenu:flex not-data-submenu:flex-col not-data-submenu:gap-0.5',
'not-data-submenu:data-child-open:blur-media-hidden',
'not-data-submenu:data-child-open:before:hidden',
'data-submenu:media-transitioning:pointer-events-none data-submenu:media-transitioning:overflow-hidden',
'data-submenu:media-transitioning:blur-media-hidden',
'absolute max-h-[inherit] overflow-auto overscroll-none outline-hidden',
'transition-[translate,filter] duration-media-menu ease-out',
'not-data-submenu:inset-x-1 not-data-submenu:top-1',
'not-data-submenu:data-child-open:-translate-x-full',
'not-data-submenu:data-child-open:rtl:translate-x-full',
'data-submenu:inset-x-0 data-submenu:top-0 data-submenu:z-10 data-submenu:max-h-[inherit] data-submenu:p-1',
'data-submenu:media-transitioning:translate-x-full data-submenu:media-transitioning:rtl:-translate-x-full'
)}
>
{children}
</Menu.Content>
</Menu.Popup>
</Menu.Root>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
/** Shared slider track. */
export type SliderTrackProps = ComponentProps<'div'>;
export function SliderTrack({ className, ...props }: SliderTrackProps) {
return (
<div
className={cn(
'relative isolate w-full select-none rounded-media-pill before:pointer-events-none before:absolute before:inset-0 before:rounded-media-control before:bg-current/20',
'data-[orientation=horizontal]:h-1 data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1',
className
)}
{...props}
/>
);
}
/** Shared slider fill. */
export type SliderFillProps = ComponentProps<'div'>;
export function SliderFill({ className, ...props }: SliderFillProps) {
return (
<div
className={cn(
'pointer-events-none absolute inset-0 before:absolute before:size-full before:rounded-media-control',
'data-[orientation=horizontal]:before:left-0 data-[orientation=horizontal]:before:min-w-1',
'data-[orientation=vertical]:before:bottom-0 data-[orientation=vertical]:before:min-h-1',
'before:bg-media-primary',
'data-[orientation=horizontal]:clip-media-x-[--media-slider-fill]',
'group-data-dragging/slider:data-[orientation=horizontal]:clip-media-x-[--media-slider-pointer]',
'data-[orientation=vertical]:clip-media-y-[--media-slider-fill]',
'group-data-dragging/slider:data-[orientation=vertical]:clip-media-y-[--media-slider-pointer]',
className
)}
{...props}
/>
);
}
/** Shared slider buffer. */
export type SliderBufferProps = ComponentProps<'div'>;
export function SliderBuffer({ className, ...props }: SliderBufferProps) {
return (
<div
className={cn(
'pointer-events-none absolute inset-0 before:absolute before:size-full before:rounded-media-control',
'data-[orientation=horizontal]:before:left-0 data-[orientation=horizontal]:before:min-w-1',
'data-[orientation=vertical]:before:bottom-0 data-[orientation=vertical]:before:min-h-1',
'before:bg-current/20',
'data-[orientation=horizontal]:clip-media-x-[--media-slider-buffer]',
'data-[orientation=vertical]:clip-media-y-[--media-slider-buffer]',
className
)}
{...props}
/>
);
}
/** Shared slider thumb. */
export type SliderThumbProps = ComponentProps<'div'>;
export function SliderThumb({ className, ...props }: SliderThumbProps) {
return (
<div
className={cn(
'absolute z-10 top-1/2 left-(--media-slider-fill,0%) size-3 -translate-x-1/2 -translate-y-1/2 rounded-media-control bg-white',
'select-none transition-[opacity,height,width,outline-offset,scale] duration-media-slider ease-out',
'group-data-dragging/slider:scale-90',
'data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill,0%))] data-[orientation=vertical]:left-1/2',
'group-data-dragging/slider:data-[orientation=horizontal]:left-(--media-slider-pointer)',
'group-data-dragging/slider:data-[orientation=vertical]:top-[calc(100%-var(--media-slider-pointer))]',
'outline-transparent shadow-media-thumb',
'outline-4 -outline-offset-4',
'hover:outline-current/15 hover:outline-offset-0 focus-visible:outline-current/15 focus-visible:outline-offset-0',
'after:pointer-events-none after:absolute after:-inset-1 after:scale-50 after:rounded-[inherit] after:opacity-0',
'after:shadow-[0_0_0_2px_currentColor] after:transition-[opacity,scale] after:duration-media-base after:ease-out',
'focus-visible:after:scale-100 focus-visible:after:opacity-100',
className
)}
{...props}
/>
);
}
@@ -0,0 +1,18 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { StatusAnnouncer as StatusAnnouncerPrimitive } from '@videojs/react';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type StatusAnnouncerProps = Omit<StatusAnnouncerPrimitive.Props, 'children'>;
export function StatusAnnouncer({ className, ...props }: StatusAnnouncerProps = {}) {
return (
<StatusAnnouncerPrimitive className={(state) => cn('sr-only', resolveClassName(className, state))} {...props} />
);
}
@@ -0,0 +1,124 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { StatusIndicator as StatusIndicatorPrimitive } from '@videojs/react';
import {
CaptionsOnIcon as CaptionsOnIconPrimitive,
CaptionsOffIcon as CaptionsOffIconPrimitive,
FullscreenEnterIcon as FullscreenEnterIconPrimitive,
FullscreenExitIcon as FullscreenExitIconPrimitive,
PipEnterIcon as PipEnterIconPrimitive,
PipExitIcon as PipExitIconPrimitive,
PlayIcon as PlayIconPrimitive,
PauseIcon as PauseIconPrimitive,
} from '@videojs/react/icons';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
const TOP_STATUS_ACTIONS = ['toggleSubtitles', 'toggleFullscreen', 'togglePictureInPicture'] as const;
const PLAYBACK_STATUS_ACTIONS = ['togglePaused'] as const;
export type StatusIndicatorProps = Omit<StatusIndicatorPrimitive.RootProps, 'children'>;
export function StatusIndicator({ className, ...props }: StatusIndicatorProps = {}) {
return (
<StatusIndicatorPrimitive.Root
actions={TOP_STATUS_ACTIONS}
className={(state) =>
cn(
'pointer-events-none absolute origin-top',
'duration-media-fast ease-out',
'media-transitioning:opacity-0 media-transitioning:duration-media-indicator media-transitioning:ease-in',
'top-3 rounded-media-control font-medium',
'bg-media-backdrop/25 text-media-popover-foreground surface-media after:surface-media-inset',
'media-high-contrast:bg-media-background',
'pointer-coarse:motion-media-[scale,translate,opacity]',
'pointer-fine:motion-media-[scale,translate,filter,opacity]',
'pointer-fine:media-transitioning:scale-media-hidden-indicator pointer-fine:media-transitioning:blur-media-hidden',
'data-ending-style:translate-y-media-hidden-indicator-offset',
'group/input-status',
resolveClassName(className, state)
)
}
{...props}
>
<div className={cn('items-center justify-between gap-2 px-2.5 py-1', 'flex', 'w-full')}>
<CaptionsOnIconPrimitive
className={cn(
'hidden shrink-0',
'group-data-[status=captions-on]/input-status:block',
'mix-blend-difference'
)}
/>
<CaptionsOffIconPrimitive
className={cn(
'hidden shrink-0',
'group-data-[status=captions-off]/input-status:block',
'mix-blend-difference'
)}
/>
<FullscreenEnterIconPrimitive
className={cn('hidden shrink-0', 'group-data-[status=fullscreen]/input-status:block', 'mix-blend-difference')}
/>
<FullscreenExitIconPrimitive
className={cn(
'hidden shrink-0',
'group-data-[status=exit-fullscreen]/input-status:block',
'mix-blend-difference'
)}
/>
<PipEnterIconPrimitive
className={cn('hidden shrink-0', 'group-data-[status=pip]/input-status:block', 'mix-blend-difference')}
/>
<PipExitIconPrimitive
className={cn('hidden shrink-0', 'group-data-[status=exit-pip]/input-status:block', 'mix-blend-difference')}
/>
<StatusIndicatorPrimitive.Value className={cn('ml-auto', 'mix-blend-difference')} />
</div>
</StatusIndicatorPrimitive.Root>
);
}
export type PlaybackStatusIndicatorProps = Omit<StatusIndicatorPrimitive.RootProps, 'children'>;
export function PlaybackStatusIndicator({ className, ...props }: PlaybackStatusIndicatorProps = {}) {
return (
<StatusIndicatorPrimitive.Root
actions={PLAYBACK_STATUS_ACTIONS}
className={(state) =>
cn(
'group/playback-status col-start-2 row-start-1 grid place-content-center p-4 text-center',
'transition-[opacity,scale] duration-media-slow ease-out',
'media-transitioning:scale-media-hidden-playback media-transitioning:opacity-0',
'data-ending-style:duration-media-fast data-ending-style:ease-in',
'rounded-media-pill bg-media-backdrop/35 backdrop-filter-media-indicator',
resolveClassName(className, state)
)
}
{...props}
>
<PlayIconPrimitive
className={cn(
'col-start-1 row-start-1 scale-media-hidden-icon opacity-0',
'transition-[opacity,scale] duration-media-base ease-out',
'group-data-[status=play]/playback-status:scale-100 group-data-[status=play]/playback-status:opacity-100',
'size-media-icon-lg',
'group-data-[status=play]/playback-status:translate-x-px'
)}
/>
<PauseIconPrimitive
className={cn(
'col-start-1 row-start-1 scale-media-hidden-icon opacity-0',
'transition-[opacity,scale] duration-media-base ease-out',
'group-data-[status=pause]/playback-status:scale-100 group-data-[status=pause]/playback-status:opacity-100',
'size-media-icon-lg'
)}
/>
</StatusIndicatorPrimitive.Root>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import type { SliderPreviewOverflow } from '@videojs/core';
import { Slider as SliderPrimitive, TimeSlider as TimeSliderPrimitive } from '@videojs/react';
import { SpinnerIcon as SpinnerIconPrimitive } from '@videojs/react/icons';
import { SliderBuffer, SliderFill, SliderThumb, SliderTrack } from '@/components/videojs/ui/slider';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export interface TimeSliderProps extends Omit<TimeSliderPrimitive.RootProps, 'children'> {
previewOverflow?: SliderPreviewOverflow | undefined;
/** Draws the thumbnail preview image in place of the one the skin renders. */
renderThumbnail?: SliderPrimitive.Thumbnail.ImageProps['render'];
}
export function TimeSlider({
className,
previewOverflow = 'visible',
renderThumbnail,
...props
}: TimeSliderProps = {}) {
return (
<TimeSliderPrimitive.Root
className={(state) =>
cn(
'group/slider relative flex flex-1 cursor-pointer items-center justify-center outline-hidden',
'data-disabled:pointer-events-none',
'transition-[--media-slider-fill,--media-slider-buffer] duration-media-slider ease-out data-dragging:duration-0',
'rounded-media-pill',
'data-[orientation=horizontal]:h-(--media-slider-height,--spacing(8))',
'data-[orientation=vertical]:w-8 data-[orientation=vertical]:min-w-0',
'data-[orientation=horizontal]:min-w-18 data-[orientation=vertical]:h-18',
'media-time-slider',
resolveClassName(className, state)
)
}
{...props}
>
<TimeSliderPrimitive.Chapters
className={'relative flex size-full min-h-0 min-w-0 flex-1 items-center rounded-[inherit]'}
renderChapter={(props) => (
<div
className={cn(
'group/chapter absolute inset-0 flex min-h-0 min-w-0 items-center justify-center',
'[--media-chapter-inset-start:0.5] [--media-chapter-inset-end:0.5]',
'first-of-type:[--media-chapter-inset-start:0] last-of-type:[--media-chapter-inset-end:0]',
'data-[orientation=horizontal]:clip-media-chapter-x data-[orientation=vertical]:clip-media-chapter-y'
)}
{...props}
>
<TimeSliderPrimitive.Track
render={<SliderTrack />}
className={cn(
'transition-[height,width] duration-media-slow ease-out',
'data-[orientation=horizontal]:before:clip-media-chapter-track-x data-[orientation=vertical]:before:clip-media-chapter-track-y',
'group-data-highlighted/chapter:data-[orientation=horizontal]:h-1.75',
'group-data-highlighted/chapter:data-[orientation=vertical]:w-1.75'
)}
>
<TimeSliderPrimitive.Buffer
render={<SliderBuffer />}
className={cn(
'group-data-highlighted/chapter:data-[orientation=horizontal]:before:min-w-1.75',
'group-data-highlighted/chapter:data-[orientation=vertical]:before:min-h-1.75'
)}
/>
<TimeSliderPrimitive.Fill
render={<SliderFill />}
className={cn(
'group-data-highlighted/chapter:data-[orientation=horizontal]:before:min-w-1.75',
'group-data-highlighted/chapter:data-[orientation=vertical]:before:min-h-1.75'
)}
/>
</TimeSliderPrimitive.Track>
</div>
)}
></TimeSliderPrimitive.Chapters>
<TimeSliderPrimitive.Thumb
render={<SliderThumb />}
className={cn(
'opacity-0 data-interactive:opacity-100 focus-visible:opacity-100',
'pointer-fine:group-hover/slider:scale-100 pointer-fine:group-hover/slider:opacity-100',
'scale-80'
)}
/>
<TimeSliderPrimitive.Preview
className={cn(
'group/preview relative h-1 [--media-slider-preview-max-height:var(--media-slider-preview-max-width)]',
'media-2xl:[--media-slider-preview-max-width:min(--spacing(48),100cqi)]',
'before:pointer-events-none before:absolute before:z-1 before:-translate-1/2 before:scale-50 before:opacity-0',
'before:transition-[opacity,scale] before:duration-media-slow before:ease-out',
'data-pointing:not-data-dragging:before:scale-100 data-pointing:not-data-dragging:before:opacity-100',
'min-w-(--media-slider-preview-max-width)',
'[--media-slider-preview-max-width:min(--spacing(32),100cqi)]',
'@min-[30rem]/media-root:[--media-slider-preview-max-width:min(--spacing(40),100cqi)]',
'before:top-1/2 before:left-1/2 before:size-1 before:rounded-media-control before:bg-current'
)}
overflow={previewOverflow}
>
<SliderPrimitive.Thumbnail.Root
className={cn(
'absolute max-w-(--media-slider-preview-max-width) -translate-x-1/2 translate-y-media-hidden-preview-offset scale-media-hidden-preview opacity-0',
'origin-bottom blur-media-hidden',
'transition-[filter,opacity,scale] duration-media-base ease-out',
'group-data-pointing/preview:scale-100 group-data-pointing/preview:opacity-100 group-data-pointing/preview:filter-none',
'group-has-focus-visible/slider:scale-100 group-has-focus-visible/slider:opacity-100 group-has-focus-visible/slider:filter-none',
'bg-media-popover text-media-popover-foreground surface-media after:surface-media-inset',
'group/thumbnail pointer-events-none overflow-hidden rounded-media-popup bg-media-backdrop/90',
'bottom-[calc(100%+var(--media-slider-preview-offset))]',
'max-h-(--media-slider-preview-max-height)',
'data-loading:aspect-video data-loading:w-(--media-slider-preview-max-width)',
'left-1/2',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-(image:--media-thumbnail-gradient)'
)}
>
<SliderPrimitive.Thumbnail.Image
render={renderThumbnail}
className={cn(
'block transition-opacity duration-media-base ease-out',
'group-data-loading/thumbnail:opacity-0'
)}
/>
<SpinnerIconPrimitive
className={cn(
'absolute top-1/2 left-1/2 z-10 size-media-icon -translate-x-1/2 -translate-y-1/2 opacity-0',
'transition-opacity duration-media-base ease-out',
'group-not-data-loading/thumbnail:[--media-spinner-animation:none]',
'group-data-loading/thumbnail:opacity-100',
'drop-shadow-media-icon'
)}
/>
</SliderPrimitive.Thumbnail.Root>
<div
className={cn(
'absolute max-w-(--media-slider-preview-max-width) -translate-x-1/2 translate-y-media-hidden-preview-offset scale-media-hidden-preview opacity-0',
'origin-bottom blur-media-hidden',
'transition-[filter,opacity,scale] duration-media-base ease-out',
'group-data-pointing/preview:scale-100 group-data-pointing/preview:opacity-100 group-data-pointing/preview:filter-none',
'group-has-focus-visible/slider:scale-100 group-has-focus-visible/slider:opacity-100 group-has-focus-visible/slider:filter-none',
'flex bottom-[calc(100%+var(--media-slider-preview-label-offset))] tabular-nums',
'left-1/2 flex-col items-center'
)}
>
<TimeSliderPrimitive.ChapterTitle
className={cn('max-w-(--media-slider-preview-max-width) min-w-0 truncate empty:hidden', 'px-6')}
/>
<TimeSliderPrimitive.Value className={'tabular-nums'} type="pointer" />
</div>
</TimeSliderPrimitive.Preview>
</TimeSliderPrimitive.Root>
);
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { Title as TitlePrimitive } from '@videojs/react';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type TitleProps = Omit<TitlePrimitive.Props, 'children'>;
export function Title({ className, ...props }: TitleProps = {}) {
return (
<TitlePrimitive
className={(state) =>
cn(
'pointer-events-none absolute inset-x-0 top-0 z-20 isolate',
'wrap-anywhere px-6 pt-2.5 text-media font-medium tracking-[-0.0125em] text-media-controls-foreground text-shadow-media',
'media-md:text-media-xl media-md:px-6 media-md:pt-4',
'origin-top duration-media-controls-enter ease-out',
'not-data-visible:opacity-0 not-data-visible:-translate-y-media-hidden-offset not-data-visible:duration-media-controls',
'pointer-fine:not-data-visible:blur-media-hidden',
'flex items-center min-h-[calc(var(--media-control-size)+(--spacing(2)))]',
'media-max-lg:pr-36',
'transition-[filter,opacity,scale,translate]',
'not-data-visible:scale-media-hidden',
resolveClassName(className, state)
)
}
{...props}
/>
);
}
@@ -0,0 +1,64 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { VolumeIndicator as VolumeIndicatorPrimitive } from '@videojs/react';
import {
VolumeHighIcon as VolumeHighIconPrimitive,
VolumeLowIcon as VolumeLowIconPrimitive,
VolumeOffIcon as VolumeOffIconPrimitive,
} from '@videojs/react/icons';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type VolumeIndicatorProps = Omit<VolumeIndicatorPrimitive.RootProps, 'children'>;
export function VolumeIndicator({ className, ...props }: VolumeIndicatorProps = {}) {
return (
<VolumeIndicatorPrimitive.Root
className={(state) =>
cn(
'pointer-events-none absolute origin-top',
'duration-media-fast ease-out',
'media-transitioning:opacity-0 media-transitioning:duration-media-indicator media-transitioning:ease-in',
'top-3 rounded-media-control font-medium',
'bg-media-backdrop/25 text-media-popover-foreground surface-media after:surface-media-inset',
'media-high-contrast:bg-media-background',
'pointer-coarse:motion-media-[scale,translate,opacity]',
'pointer-fine:motion-media-[scale,translate,filter,opacity]',
'pointer-fine:media-transitioning:scale-media-hidden-indicator pointer-fine:media-transitioning:blur-media-hidden',
'data-ending-style:translate-y-media-hidden-indicator-offset',
'group/volume-status',
'w-[min(80%,12rem)] transform-[translateX(0)]',
'motion-safe:[&:is([data-min],[data-max]):not([data-starting-style],[data-ending-style])]:nudge-media',
resolveClassName(className, state)
)
}
{...props}
>
<VolumeIndicatorPrimitive.Fill
className={cn(
'items-center justify-between gap-2 px-2.5 py-1',
'rounded-[inherit]',
'flex w-full bg-left bg-no-repeat',
'bg-[linear-gradient(currentColor,currentColor)]',
'bg-size-[var(--media-volume-fill,0%)_100%] transition-[background-size] duration-media-slow ease-linear'
)}
>
<VolumeHighIconPrimitive
className={cn('hidden shrink-0 group-data-[level=high]/volume-status:block', 'mix-blend-difference')}
/>
<VolumeLowIconPrimitive
className={cn('hidden shrink-0 group-data-[level=low]/volume-status:block', 'mix-blend-difference')}
/>
<VolumeOffIconPrimitive
className={cn('hidden shrink-0 group-data-[level=off]/volume-status:block', 'mix-blend-difference')}
/>
<VolumeIndicatorPrimitive.Value className={'ml-auto mix-blend-difference'} />
</VolumeIndicatorPrimitive.Fill>
</VolumeIndicatorPrimitive.Root>
);
}
@@ -0,0 +1,63 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import type { VolumeSliderProps as CoreVolumeSliderProps } from '@videojs/core';
import { VolumePopover as VolumePopoverPrimitive } from '@videojs/react';
import type { ClassValue } from 'cn';
import { ButtonTooltip } from '@/components/videojs/ui/button-tooltip';
import { MuteButton } from '@/components/videojs/ui/mute-button';
import { VolumeSlider } from '@/components/videojs/ui/volume-slider';
import { cn } from '@/lib/utils';
export interface VolumePopoverProps extends Omit<VolumePopoverPrimitive.RootProps, 'children'> {
className?: ClassValue;
orientation?: CoreVolumeSliderProps['orientation'];
showTooltip?: boolean;
}
export function VolumePopover({
className,
showTooltip = false,
side = 'top',
orientation = 'vertical',
...props
}: VolumePopoverProps = {}) {
return (
<VolumePopoverPrimitive.Root openOnHover delay={200} closeDelay={100} side={side} {...props}>
<ButtonTooltip delay={0} disabled={!showTooltip} sticky side="top">
<VolumePopoverPrimitive.Trigger render={<MuteButton className={cn(className)} />} />
</ButtonTooltip>
<VolumePopoverPrimitive.Popup
className={cn(
'm-0 overflow-visible border-0 text-inherit',
'media-transitioning:opacity-0 media-transitioning:blur-media-hidden-popup media-transitioning:scale-media-hidden-popup',
'data-starting-style:transform-[translate(var(--media-popup-translate-x-distance,0),var(--media-popup-translate-y-distance,0))]',
'data-ending-style:transform-none',
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left',
'data-[side=top]:[--media-popup-translate-y-distance:var(--media-popup-translate-distance)]',
'data-[side=bottom]:[--media-popup-translate-y-distance:calc(var(--media-popup-translate-distance)*-1)]',
'data-[side=left]:[--media-popup-translate-x-distance:var(--media-popup-translate-distance)]',
'data-[side=right]:[--media-popup-translate-x-distance:calc(var(--media-popup-translate-distance)*-1)]',
'before:pointer-events-auto before:absolute',
'data-[side=top]:before:inset-x-0 data-[side=top]:before:top-full',
'data-[side=bottom]:before:inset-x-0 data-[side=bottom]:before:bottom-full',
'data-[side=left]:before:inset-y-0 data-[side=left]:before:left-full',
'data-[side=right]:before:inset-y-0 data-[side=right]:before:right-full',
'data-[side=top]:before:h-(--media-popup-side-offset) data-[side=bottom]:before:h-(--media-popup-side-offset)',
'data-[side=left]:before:w-(--media-popup-side-offset) data-[side=right]:before:w-(--media-popup-side-offset)',
'transition-media-popup data-ending-style:duration-media-instant',
'bg-media-popover text-media-popover-foreground surface-media after:surface-media-inset',
'rounded-media-control px-0 py-3 [--media-popup-side-offset:var(--media-popover-side-offset)]',
'data-[side=right]:rounded-none data-[side=right]:p-0 data-[side=right]:px-3 data-[side=right]:surface-media-none! data-[side=right]:after:hidden',
'data-[side=right]:[--media-popover-side-offset:0rem]'
)}
>
<VolumeSlider orientation={orientation} />
</VolumePopoverPrimitive.Popup>
</VolumePopoverPrimitive.Root>
);
}
@@ -0,0 +1,40 @@
'use client';
import '../styles/base.css';
import '../styles/audio/theme.css';
import '../styles/video/captions.css';
import '../styles/video/theme.css';
import { VolumeSlider as VolumeSliderPrimitive } from '@videojs/react';
import { SliderFill, SliderThumb, SliderTrack } from '@/components/videojs/ui/slider';
import { resolveClassName } from '@/lib/resolve-class-name';
import { cn } from '@/lib/utils';
export type VolumeSliderProps = Omit<VolumeSliderPrimitive.RootProps, 'children'>;
export function VolumeSlider({ className, ...props }: VolumeSliderProps = {}) {
return (
<VolumeSliderPrimitive.Root
className={(state) =>
cn(
'group/slider relative flex flex-1 cursor-pointer items-center justify-center outline-hidden',
'data-disabled:pointer-events-none',
'transition-[--media-slider-fill,--media-slider-buffer] duration-media-slider ease-out data-dragging:duration-0',
'rounded-media-pill',
'data-[orientation=horizontal]:h-(--media-slider-height,--spacing(8))',
'data-[orientation=vertical]:w-8 data-[orientation=vertical]:min-w-0',
'data-[orientation=horizontal]:min-w-18 data-[orientation=vertical]:h-18',
'media-volume-slider',
resolveClassName(className, state)
)
}
thumbAlignment="edge"
{...props}
>
<VolumeSliderPrimitive.Track render={<SliderTrack />}>
<VolumeSliderPrimitive.Fill render={<SliderFill />} />
</VolumeSliderPrimitive.Track>
<VolumeSliderPrimitive.Thumb render={<SliderThumb />} className={'scale-100 opacity-100'} />
</VolumeSliderPrimitive.Root>
);
}
@@ -0,0 +1,17 @@
import { Gesture } from '@videojs/react';
export interface VideoGesturesProps {
disabled?: boolean | undefined;
}
export function VideoGestures({ disabled = false }: VideoGesturesProps = {}) {
return (
<>
<Gesture disabled={disabled} type="tap" action="togglePaused" pointer="mouse" region="center" />
<Gesture disabled={disabled} type="tap" action="toggleControls" pointer="touch" />
<Gesture disabled={disabled} type="doubletap" action="seekStep" region="left" />
<Gesture disabled={disabled} type="doubletap" action="toggleFullscreen" region="center" />
<Gesture disabled={disabled} type="doubletap" action="seekStep" region="right" />
</>
);
}
@@ -0,0 +1,18 @@
import { Hotkey } from '@videojs/react';
import { PlaybackHotkeys } from './playback-hotkeys';
export interface VideoHotkeysProps {
disabled?: boolean | undefined;
}
export function VideoHotkeys({ disabled = false }: VideoHotkeysProps = {}) {
return (
<>
<PlaybackHotkeys disabled={disabled} />
<Hotkey disabled={disabled} keys="f" action="toggleFullscreen" />
<Hotkey disabled={disabled} keys="c" action="toggleSubtitles" />
<Hotkey disabled={disabled} keys="i" action="togglePictureInPicture" />
</>
);
}
@@ -0,0 +1,26 @@
import { Hotkey } from '@videojs/react';
export interface PlaybackHotkeysProps {
disabled?: boolean | undefined;
}
export function PlaybackHotkeys({ disabled = false }: PlaybackHotkeysProps = {}) {
return (
<>
<Hotkey disabled={disabled} keys="Space" action="togglePaused" />
<Hotkey disabled={disabled} keys="k" action="togglePaused" />
<Hotkey disabled={disabled} keys="m" action="toggleMuted" />
<Hotkey disabled={disabled} keys="ArrowRight" action="seekStep" />
<Hotkey disabled={disabled} keys="ArrowLeft" action="seekStep" />
<Hotkey disabled={disabled} keys="l" action="seekStep" />
<Hotkey disabled={disabled} keys="j" action="seekStep" />
<Hotkey disabled={disabled} keys="ArrowUp" action="volumeStep" />
<Hotkey disabled={disabled} keys="ArrowDown" action="volumeStep" />
<Hotkey disabled={disabled} keys="0-9" action="seekToPercent" />
<Hotkey disabled={disabled} keys="Home" action="seekToPercent" value={0} />
<Hotkey disabled={disabled} keys="End" action="seekToPercent" value={100} />
<Hotkey disabled={disabled} keys=">" action="speedUp" />
<Hotkey disabled={disabled} keys="<" action="speedDown" />
</>
);
}
@@ -0,0 +1,30 @@
import type { ComponentProps } from 'react';
import { SeekIndicator } from '@/components/videojs/ui/seek-indicator';
import { StatusAnnouncer } from '@/components/videojs/ui/status-announcer';
import { PlaybackStatusIndicator, StatusIndicator } from '@/components/videojs/ui/status-indicator';
import { VolumeIndicator } from '@/components/videojs/ui/volume-indicator';
import { cn } from '@/lib/utils';
export type VideoStatusIndicatorsProps = Omit<ComponentProps<'div'>, 'children'>;
export function VideoStatusIndicators({ className, ...props }: VideoStatusIndicatorsProps = {}) {
return (
<>
<StatusAnnouncer />
<div
className={cn(
'pointer-events-none absolute inset-0 z-20 grid grid-cols-3 items-center justify-items-center',
'text-media-controls-foreground',
className
)}
{...props}
>
<VolumeIndicator />
<StatusIndicator />
<SeekIndicator />
<PlaybackStatusIndicator />
</div>
</>
);
}
@@ -0,0 +1,132 @@
import { Controls, Time, Tooltip } from '@videojs/react';
import type { ComponentProps } from 'react';
import { AirPlayButton } from '@/components/videojs/ui/airplay-button';
import { ButtonTooltip } from '@/components/videojs/ui/button-tooltip';
import { CaptionsButton } from '@/components/videojs/ui/captions-button';
import { CastButton } from '@/components/videojs/ui/cast-button';
import { FullscreenButton } from '@/components/videojs/ui/fullscreen-button';
import { PiPButton } from '@/components/videojs/ui/pip-button';
import { PlayButton } from '@/components/videojs/ui/play-button';
import { TimeSlider } from '@/components/videojs/ui/time-slider';
import { VolumePopover } from '@/components/videojs/ui/volume-popover';
import { cn } from '@/lib/utils';
import { VideoSettingsMenu } from '../menus/settings-menu';
export interface DefaultVideoControlsProps {
renderThumbnail?: NonNullable<ComponentProps<typeof TimeSlider>>['renderThumbnail'];
}
export function DefaultVideoControls({ renderThumbnail }: DefaultVideoControlsProps = {}) {
return (
<Controls.Root>
<Controls.Backdrop
className={cn(
'pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-(image:--media-controls-gradient)',
'transition-opacity duration-media-controls ease-out not-data-visible:opacity-0'
)}
/>
<Controls.Content
className={cn(
'video-controls',
'group/controls text-media-controls-foreground text-shadow-media',
'duration-media-controls-enter ease-out',
'contents p-1 transition-[filter,opacity,scale,translate]',
'media-lg:absolute media-lg:inset-x-2 media-lg:bottom-2 media-lg:z-30',
'media-lg:flex media-lg:items-center media-lg:rtl:flex-row-reverse media-lg:rounded-media-controls',
'media-lg:bg-media-popover media-lg:text-media-popover-foreground',
'media-lg:surface-media media-lg:after:surface-media-inset',
'media-2xl:inset-x-3 media-2xl:bottom-3',
'media-lg:not-data-visible:pointer-events-none media-lg:not-data-visible:opacity-0',
'media-lg:not-data-visible:scale-media-hidden media-lg:not-data-visible:translate-y-media-hidden-offset',
'media-lg:pointer-fine:not-data-visible:blur-media-hidden',
'media-lg:not-data-visible:duration-media-controls'
)}
>
<Tooltip.Provider>
<Controls.Group
className={cn(
'absolute inset-x-2 bottom-2 z-30 flex origin-bottom items-center rtl:flex-row-reverse rounded-media-controls p-0.5',
'media-max-lg:bg-media-popover media-max-lg:text-media-popover-foreground',
'media-max-lg:surface-media media-max-lg:after:surface-media-inset',
'media-lg:contents',
'media-max-lg:group-[:not([data-visible])]/controls:pointer-events-none',
'media-max-lg:group-[:not([data-visible])]/controls:opacity-0',
'media-max-lg:group-[:not([data-visible])]/controls:scale-media-hidden',
'media-max-lg:pointer-fine:group-[:not([data-visible])]/controls:blur-media-hidden',
'transition-[filter,opacity,scale,translate] duration-media-controls-enter ease-out',
'media-max-lg:group-[:not([data-visible])]/controls:duration-media-controls',
'media-max-lg:group-[:not([data-visible])]/controls:translate-y-media-hidden-offset'
)}
>
<ButtonTooltip side="top">
<PlayButton />
</ButtonTooltip>
<VolumePopover className={'ms-px'} />
<Controls.Group
className={
'@container/media-time flex flex-1 items-center rtl:flex-row-reverse gap-2.5 px-2 media-lg:px-3'
}
>
<Time.Value
className={cn(
'tabular-nums transition-opacity duration-media-slow ease-out data-unavailable:opacity-50',
'@max-[16rem]/media-time:hidden'
)}
type="current"
/>
<TimeSlider renderThumbnail={renderThumbnail} />
<Time.Value
className={cn(
'cursor-pointer rounded-sm tabular-nums focus-ring-media',
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
'transition-[outline-color,outline-offset] duration-media-fast ease-out',
'focus-visible:outline-media-ring focus-visible:outline-offset-2',
'@max-[16rem]/media-time:hidden'
)}
type="remaining"
toggle
/>
</Controls.Group>
<ButtonTooltip side="top">
<CaptionsButton className={'media-max-lg:hidden'} />
</ButtonTooltip>
<VideoSettingsMenu className={'media-lg:ms-px'} />
</Controls.Group>
<Controls.Group
className={cn(
'absolute top-2 right-2 z-30 flex origin-top items-center rtl:flex-row-reverse gap-px rounded-media-controls p-0.5',
'media-max-lg:bg-media-popover media-max-lg:text-media-popover-foreground',
'media-max-lg:surface-media media-max-lg:after:surface-media-inset',
'media-lg:static media-lg:p-0',
'media-max-lg:group-[:not([data-visible])]/controls:pointer-events-none',
'media-max-lg:group-[:not([data-visible])]/controls:opacity-0',
'media-max-lg:group-[:not([data-visible])]/controls:scale-media-hidden',
'media-max-lg:pointer-fine:group-[:not([data-visible])]/controls:blur-media-hidden',
'transition-[filter,opacity,scale,translate] duration-media-controls-enter ease-out',
'media-max-lg:group-[:not([data-visible])]/controls:duration-media-controls',
'media-max-lg:group-[:not([data-visible])]/controls:-translate-y-media-hidden-offset'
)}
>
<ButtonTooltip side="top">
<CastButton />
</ButtonTooltip>
<ButtonTooltip side="top">
<AirPlayButton />
</ButtonTooltip>
<ButtonTooltip side="top">
<PiPButton />
</ButtonTooltip>
<ButtonTooltip side="top">
<FullscreenButton />
</ButtonTooltip>
</Controls.Group>
</Tooltip.Provider>
</Controls.Content>
</Controls.Root>
);
}
@@ -0,0 +1,20 @@
import type { ComponentProps } from 'react';
import { AudioTrackMenu } from '@/components/videojs/ui/audio-track-menu';
import { CaptionsSubmenu } from '@/components/videojs/ui/captions-submenu';
import { PlaybackRateSubmenu } from '@/components/videojs/ui/playback-rate-submenu';
import { QualityMenu } from '@/components/videojs/ui/quality-menu';
import { SettingsMenu } from '@/components/videojs/ui/settings-menu';
export type VideoSettingsMenuProps = Omit<NonNullable<ComponentProps<typeof SettingsMenu>>, 'children'>;
export function VideoSettingsMenu(props: VideoSettingsMenuProps = {}) {
return (
<SettingsMenu {...props}>
<QualityMenu />
<AudioTrackMenu />
<PlaybackRateSubmenu />
<CaptionsSubmenu />
</SettingsMenu>
);
}
+45
View File
@@ -0,0 +1,45 @@
'use client';
import '../styles/video/base.css';
import type { ComponentProps, ReactNode } from 'react';
import { BufferingIndicator } from '@/components/videojs/ui/buffering-indicator';
import { Container } from '@/components/videojs/ui/container';
import { ErrorDialog } from '@/components/videojs/ui/error-dialog';
import { Poster } from '@/components/videojs/ui/poster';
import { Title } from '@/components/videojs/ui/title';
import { cn } from '@/lib/utils';
import { VideoGestures } from './behaviors/gestures';
import { VideoHotkeys } from './behaviors/hotkeys';
import { VideoStatusIndicators } from './display/status-indicators';
import { DefaultVideoControls } from './layout/controls';
export interface VideoSkinProps extends Omit<NonNullable<ComponentProps<typeof Container>>, 'children'> {
children?: ReactNode;
renderPoster?: NonNullable<ComponentProps<typeof Poster>>['renderImage'];
renderThumbnail?: NonNullable<ComponentProps<typeof DefaultVideoControls>>['renderThumbnail'];
}
export function VideoSkin({ children, className, renderPoster, renderThumbnail, ...props }: VideoSkinProps = {}) {
return (
<Container
className={cn('pointer-fine:not-data-controls-visible:cursor-none', className)}
data-theme="default"
data-preset="video"
{...props}
>
{children}
<Poster renderImage={renderPoster} />
<BufferingIndicator />
<ErrorDialog />
<Title />
<DefaultVideoControls renderThumbnail={renderThumbnail} />
<VideoHotkeys />
<VideoGestures />
<VideoStatusIndicators />
</Container>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClassValue } from 'cn';
export type ClassName<State> = ClassValue | ((state: State) => ClassValue);
export function resolveClassName<State>(className: ClassName<State>, state: State): ClassValue {
return typeof className === 'function' ? className(state) : className;
}