feat(skin): add error dialogs (#603)

This commit is contained in:
Sam Potts
2026-02-25 14:38:57 +11:00
committed by GitHub
parent 78112fbefd
commit ba94f74b91
25 changed files with 1641 additions and 477 deletions
+303 -134
View File
@@ -1,141 +1,265 @@
/* ==========================================================================
Reset
========================================================================== */
.media-minimal-skin *,
.media-minimal-skin *::before,
.media-minimal-skin *::after {
box-sizing: border-box;
margin: 0;
}
.media-minimal-skin img,
.media-minimal-skin video,
.media-minimal-skin svg {
display: block;
max-width: 100%;
}
.media-minimal-skin button {
font: inherit;
}
@media (prefers-reduced-motion: no-preference) {
.media-minimal-skin {
interpolate-size: allow-keywords;
}
}
/* ==========================================================================
Root Container
========================================================================== */
.media-minimal-skin {
position: relative;
isolation: isolate;
container: media-root / inline-size;
overflow: clip;
border-radius: var(--media-border-radius, 0.75rem);
background: oklab(0 0 0);
font-family: ui-sans-serif, system-ui, sans-serif;
background: oklch(0 0 0);
font-family:
Inter Variable,
Inter,
ui-sans-serif,
system-ui,
sans-serif;
font-size: 0.8125rem;
line-height: 1.5;
letter-spacing: normal;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
}
.media-minimal-skin * {
box-sizing: border-box;
}
/* Border ring */
.media-minimal-skin::after {
content: "";
position: absolute;
pointer-events: none;
border-radius: inherit;
z-index: 10;
inset: 0;
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.15);
z-index: 10;
border-radius: inherit;
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.15);
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.media-minimal-skin::after {
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
}
}
/* Buffering indicator */
.media-minimal-skin .media-buffering-indicator {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
color: #fff;
}
/* Poster image */
.media-minimal-skin > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
transition: opacity 0.25s;
}
.media-minimal-skin > img:not([data-visible]) {
opacity: 0;
}
/* Fullscreen */
.media-minimal-skin:fullscreen {
border-radius: 0;
}
/* ==========================================================================
Media Element
========================================================================== */
.media-minimal-skin > video {
display: block;
width: 100%;
height: 100%;
}
/* Media Container UI Overlay Styling */
/* ==========================================================================
Poster Image
========================================================================== */
.media-minimal-skin > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.25s;
pointer-events: none;
}
.media-minimal-skin > img:not([data-visible]) {
opacity: 0;
}
/* ==========================================================================
Overlay / Scrim
========================================================================== */
.media-minimal-skin .media-overlay {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
background-image: linear-gradient(to top, oklab(0 0 0 / 0.7), oklab(0 0 0 / 0.5) 7.5rem, rgba(0, 0, 0, 0));
backdrop-filter: saturate(1.5) brightness(0.9);
transition-delay: 500ms;
transition-duration: 500ms;
transition-behavior: allow-discrete;
transition-property: opacity, display;
transition-timing-function: ease-out;
background-image: linear-gradient(to top, oklch(0 0 0 / 0.7), oklch(0 0 0 / 0.5) 7.5rem, oklch(0 0 0 / 0));
backdrop-filter: blur(0) saturate(1.5) brightness(0.9);
opacity: 0;
display: none;
transition-property: opacity, backdrop-filter;
transition-duration: 500ms;
transition-delay: 500ms;
transition-timing-function: ease-out;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-minimal-skin .media-overlay {
transition-duration: 100ms;
}
}
.media-minimal-skin .media-controls[data-visible] ~ .media-overlay {
transition-duration: 50ms;
transition-delay: 0ms;
.media-minimal-skin .media-controls[data-visible] ~ .media-overlay,
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
opacity: 1;
display: block;
transition-duration: 150ms;
transition-delay: 0ms;
}
.media-minimal-skin .media-error[data-visible] ~ .media-overlay {
backdrop-filter: blur(8px) saturate(1.5) brightness(0.9);
}
/* Media Control Bar UI/Styles */
/* ==========================================================================
Buffering Indicator
========================================================================== */
.media-minimal-skin .media-buffering-indicator {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
color: oklch(1 0 0);
pointer-events: none;
}
/* ==========================================================================
Error Dialog
========================================================================== */
.media-minimal-skin .media-error {
display: none;
}
.media-minimal-skin .media-error[data-visible] {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.media-minimal-skin .media-error__dialog {
display: none;
transition-property: display, opacity, transform;
transition-duration: 500ms;
transition-delay: 100ms;
transition-behavior: allow-discrete;
transition-timing-function: linear(
0,
0.034 1.5%,
0.763 9.7%,
1.066 13.9%,
1.198 19.9%,
1.184 21.8%,
0.963 37.5%,
0.997 50.9%,
1
);
}
.media-minimal-skin .media-error[data-visible] .media-error__dialog {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 16rem;
padding: 1rem;
color: oklch(1 0 0);
font-size: 0.875rem;
text-shadow: 0 1px 0 oklch(0 0 0 / 0.5);
pointer-events: auto;
transform: scale(1);
opacity: 1;
color: oklch(1 0 0);
@starting-style {
transform: scale(0.5);
opacity: 0;
}
}
.media-minimal-skin .media-error__content {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.375rem 0;
}
.media-minimal-skin .media-error__title {
font-weight: 600;
line-height: 1.25;
}
.media-minimal-skin .media-error__actions {
display: flex;
gap: 0.5rem;
}
.media-minimal-skin .media-error__actions > * {
flex: 1;
}
/* ==========================================================================
Controls
========================================================================== */
.media-minimal-skin .media-controls {
position: absolute;
container: media-controls / inline-size;
bottom: 0;
inset-inline: 0;
padding: 2rem 0.375rem 0.375rem 0.375rem;
z-index: 10;
container: media-controls / inline-size;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 2rem 0.375rem 0.375rem 0.375rem;
color: oklch(1 0 0);
will-change: transform, filter, opacity;
transition-property: transform, filter, opacity;
transition-delay: 0ms;
transition-duration: 75ms;
transition-delay: 0ms;
transition-timing-function: ease-out;
color: oklab(1 0 0);
z-index: 10;
}
.media-minimal-skin .media-controls:not([data-visible]) {
opacity: 0;
transform: translateY(100%);
filter: blur(8px);
transition-delay: 500ms;
transition-duration: 500ms;
transition-delay: 500ms;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-minimal-skin .media-controls:not([data-visible]) {
transition-duration: 100ms;
scale: 1;
transform: translateY(0);
filter: blur(0);
scale: 1;
transition-duration: 100ms;
}
}
@container media-root (width > 40rem) {
.media-minimal-skin .media-controls {
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
gap: 0.875rem;
padding: 2.5rem 0.75rem 0.75rem 0.75rem;
}
}
/* Time Controls */
/* ==========================================================================
Time Controls & Display
========================================================================== */
.media-minimal-skin .media-time-controls {
display: flex;
flex-direction: row-reverse;
@@ -148,21 +272,21 @@
align-items: center;
gap: 0.25rem;
}
.media-minimal-skin .media-time__value {
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 0 oklch(0 0 0 / 0.2);
}
.media-minimal-skin .media-time__value--current,
.media-minimal-skin .media-time__separator {
display: none;
}
.media-minimal-skin .media-time__value {
text-shadow: 0 1px 0 oklab(0 0 0 / 0.2);
font-variant-numeric: tabular-nums;
}
@container media-controls (width > 28rem) {
.media-minimal-skin .media-time-controls {
flex-direction: row;
}
.media-minimal-skin .media-time__value--duration,
.media-minimal-skin .media-time__separator {
color: oklab(1 0 0 / 0.5);
color: oklch(1 0 0 / 0.5);
}
.media-minimal-skin .media-time__value--current,
.media-minimal-skin .media-time__separator {
@@ -170,7 +294,10 @@
}
}
/* Button Groups (layout only) */
/* ==========================================================================
Button Groups
========================================================================== */
.media-minimal-skin .media-button-group {
display: flex;
align-items: center;
@@ -182,45 +309,68 @@
}
}
/* Buttons */
/* ==========================================================================
Buttons
========================================================================== */
/* Base button */
.media-minimal-skin .media-button {
display: grid;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.625rem;
cursor: pointer;
background: transparent;
padding: 0.5rem 1rem;
background: oklch(1 0 0);
border: none;
border-radius: 0.375rem;
color: oklab(1 0 0);
user-select: none;
border-radius: 0.5rem;
outline: 2px solid transparent;
outline-offset: -2px;
color: oklch(0 0 0);
font-weight: 500;
transition-property: background-color, color, outline-offset;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.media-minimal-skin .media-button:hover,
.media-minimal-skin .media-button:focus-visible,
.media-minimal-skin .media-button[aria-expanded="true"] {
color: oklab(1 0 0 / 0.8);
text-decoration: none;
cursor: pointer;
user-select: none;
}
.media-minimal-skin .media-button:focus-visible {
outline-color: oklab(1 0 0);
outline-color: oklch(1 0 0);
outline-offset: 2px;
}
.media-minimal-skin .media-button[disabled] {
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(1);
cursor: not-allowed;
}
/* Icon button variant */
.media-minimal-skin .media-button--icon {
display: grid;
padding: 0.625rem;
background: transparent;
color: oklch(1 0 0);
}
.media-minimal-skin .media-button--icon:hover,
.media-minimal-skin .media-button--icon:focus-visible,
.media-minimal-skin .media-button--icon[aria-expanded="true"] {
color: oklch(1 0 0 / 0.8);
text-decoration: none;
}
.media-minimal-skin .media-button--icon .media-icon {
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
}
/* Seek button variant — hidden at small sizes */
@container media-controls (width < 28rem) {
.media-minimal-skin .media-button--seek {
display: none;
}
}
/* Icons */
/* ==========================================================================
Icons
========================================================================== */
.media-minimal-skin .media-icon__container {
position: relative;
}
@@ -228,7 +378,6 @@
grid-area: 1 / 1;
width: 18px;
height: 18px;
filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.25));
transition-behavior: allow-discrete;
transition-property: display, opacity;
transition-duration: 150ms;
@@ -241,89 +390,92 @@
.media-minimal-skin .media-icon--flipped {
scale: -1 1;
}
/* Seek icon label positioning */
.media-minimal-skin .media-icon--seek ~ .media-icon__label {
position: absolute;
right: 0;
bottom: -3px;
font-size: 0.75em;
font-weight: 480;
font-variant-numeric: tabular-nums;
}
.media-minimal-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
left: 0;
right: unset;
left: 0;
}
/* TimeSlider Component Styles */
/* ==========================================================================
Slider
========================================================================== */
.media-minimal-skin .media-slider {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex: 1;
border-radius: calc(infinity * 1px);
outline: none;
}
/* Horizontal orientation styles */
/* Horizontal orientation */
.media-minimal-skin .media-slider[data-orientation="horizontal"] {
min-width: 5rem;
width: 100%;
height: 1.25rem;
}
/* Vertical orientation styles */
/* Vertical orientation */
.media-minimal-skin .media-slider[data-orientation="vertical"] {
height: 4.5rem;
width: 1.25rem;
height: 4.5rem;
}
/* Track */
.media-minimal-skin .media-slider__track {
position: relative;
isolation: isolate;
background-color: oklab(1 0 0 / 0.2);
border-radius: inherit;
overflow: hidden;
user-select: none;
background-color: oklch(1 0 0 / 0.2);
border-radius: inherit;
outline: 2px solid transparent;
outline-offset: -2px;
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
transition: outline-offset 150ms ease-out;
box-shadow: 0 0 0 1px oklab(0 0 0 / 0.05);
user-select: none;
}
/* Horizontal track styles */
.media-minimal-skin .media-slider__track[data-orientation="horizontal"] {
width: 100%;
height: 0.1875rem;
}
/* Vertical track styles */
.media-minimal-skin .media-slider__track[data-orientation="vertical"] {
width: 0.1875rem;
height: 100%;
}
.media-minimal-skin .media-slider:focus-visible .media-slider__track {
outline-color: oklab(1 0 0);
outline-color: oklch(1 0 0);
outline-offset: 6px;
}
/* Thumb */
.media-minimal-skin .media-slider__thumb {
z-index: 10;
width: 0.75rem;
height: 0.75rem;
background-color: oklab(1 0 0);
background-color: oklch(1 0 0);
border-radius: calc(infinity * 1px);
user-select: none;
z-index: 10;
box-shadow:
0 0 0 1px oklab(0 0 0 / 0.1),
0 1px 3px 0 oklab(0 0 0 / 0.15),
0 1px 2px -1px oklab(0 0 0 / 0.15);
0 0 0 1px oklch(0 0 0 / 0.1),
0 1px 3px 0 oklch(0 0 0 / 0.15),
0 1px 2px -1px oklch(0 0 0 / 0.15);
opacity: 0;
scale: 0.7;
transform-origin: center;
transition-property: opacity, scale;
transition-duration: 150ms;
transition-timing-function: ease-out;
user-select: none;
}
.media-minimal-skin .media-slider:hover .media-slider__thumb,
.media-minimal-skin .media-slider:focus-within .media-slider__thumb {
@@ -337,31 +489,38 @@
cursor: ns-resize;
}
/* Pointer (hidden in minimal skin) */
.media-minimal-skin .media-slider__pointer {
display: none;
}
/* Progress fill */
.media-minimal-skin .media-slider__progress {
background-color: oklab(1 0 0);
background-color: oklch(1 0 0);
border-radius: inherit;
}
/* Time display within slider */
.media-minimal-skin .media-slider__time-display {
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Popups & Animations
========================================================================== */
.media-minimal-skin .media-popup-animation {
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
opacity: 1;
transform: scale(1);
transform-origin: bottom;
opacity: 1;
filter: blur(0px);
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
}
.media-minimal-skin .media-popup-animation[data-starting-style],
.media-minimal-skin .media-popup-animation[data-ending-style] {
transform: scale(0);
opacity: 0;
transform: scale(0);
filter: blur(8px);
}
.media-minimal-skin .media-popup-animation[data-instant] {
@@ -369,49 +528,55 @@
}
.media-minimal-skin .media-popover-popup {
padding: 0.5rem;
margin: 0;
padding: 0.5rem;
border: 0;
background: transparent;
}
/* Tooltip Component Styles */
/* ==========================================================================
Tooltips
========================================================================== */
.media-minimal-skin .media-tooltip {
white-space: nowrap;
}
.media-minimal-skin .media-tooltip-popup {
color: oklab(1 0 0);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
background-color: oklab(1 0 0 / 0.1);
background-color: oklch(1 0 0 / 0.1);
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
box-shadow:
0 4px 6px -1px oklab(0 0 0 / 0.1),
0 2px 4px -2px oklab(0 0 0 / 0.1);
0 4px 6px -1px oklch(0 0 0 / 0.1),
0 2px 4px -2px oklch(0 0 0 / 0.1);
color: oklch(1 0 0);
font-size: 0.75rem;
}
@media (prefers-reduced-transparency: reduce) {
.media-minimal-skin .media-tooltip-popup {
background-color: oklab(0 0 0 / 0.7);
background-color: oklch(0 0 0 / 0.7);
}
}
@media (prefers-contrast: more) {
.media-minimal-skin .media-tooltip-popup {
background-color: oklab(0 0 0 / 0.9);
background-color: oklch(0 0 0 / 0.9);
}
}
/* Captions */
/* ==========================================================================
Captions
========================================================================== */
.media-minimal-skin .media-captions {
position: absolute;
inset: auto 1rem 1.5rem 1rem;
z-index: 20;
transition: transform 150ms ease-out;
font-size: 1rem;
text-wrap: balance;
/* The delay must account for the controls delay/duration */
transition: transform 150ms ease-out;
transition-delay: 600ms;
pointer-events: none;
text-wrap: balance;
font-size: 1rem;
}
@media (prefers-reduced-motion: reduce) {
.media-minimal-skin .media-captions {
@@ -419,34 +584,36 @@
}
}
.media-minimal-skin .media-captions__container {
max-width: 42ch;
margin: 0 auto;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
max-width: 42ch;
margin: 0 auto;
text-align: center;
}
.media-minimal-skin .media-captions__text {
display: block;
padding: 0.125rem 0.5rem;
color: oklch(1 0 0);
text-shadow:
0 0 1px oklab(0 0 0 / 0.7),
0 0 8px oklab(0 0 0 / 0.7);
color: #fff;
0 0 1px oklch(0 0 0 / 0.7),
0 0 8px oklch(0 0 0 / 0.7);
text-align: center;
white-space: pre-wrap;
line-height: 1.2;
}
@media (prefers-contrast: more) {
.media-minimal-skin .media-captions__text {
background: oklch(0 0 0 / 0.7);
text-shadow: none;
box-decoration-break: clone;
background: oklab(0 0 0 / 0.7);
}
}
.media-minimal-skin .media-captions__text > * {
display: inline;
}
/* Responsive caption sizing */
@container media-root (width > 20rem) {
.media-minimal-skin .media-captions {
font-size: 1.5rem;
@@ -462,6 +629,8 @@
font-size: 2.25rem;
}
}
/* Shift captions up when controls visible */
.media-minimal-skin .media-controls[data-visible] ~ .media-captions {
transform: translateY(-2.5rem);
transition-delay: 25ms;
@@ -17,6 +17,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
@@ -31,7 +32,7 @@ const SEEK_TIME = 10;
const icon = cn(
'[grid-area:1/1] size-4.5',
'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
'drop-shadow-[0_1px_0_var(--tw-drop-shadow-color)] drop-shadow-black/25',
'transition-discrete transition-[display,opacity] duration-150 ease-out'
);
@@ -39,25 +40,35 @@ const iconHidden = 'hidden opacity-0';
/* --------------------------------------- Components ---------------------------------------- */
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button({ className, ...props }, ref) {
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
{ className, variant, ...props },
ref
) {
return (
<button
ref={ref}
type="button"
className={cn(
// Layout
'grid shrink-0 p-2.5 cursor-pointer bg-transparent border-none rounded-md',
'text-white select-none',
// Shared
'shrink-0 border-none cursor-pointer select-none',
'outline-2 outline-transparent -outline-offset-2',
// Transitions
'transition-[background-color,color,outline-offset] duration-150 ease-out',
// Hover / focus / expanded
'hover:text-white/80 hover:no-underline',
'focus-visible:text-white/80',
'focus-visible:outline-white focus-visible:outline-offset-2',
'aria-expanded:text-white/80',
// Disabled
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
// Variant
variant === 'icon'
? cn(
'grid p-2.5 bg-transparent rounded-md',
'text-white',
'hover:text-white/80 hover:no-underline',
'focus-visible:text-white/80',
'focus-visible:outline-white focus-visible:outline-offset-2',
'aria-expanded:text-white/80'
)
: cn(
'flex items-center justify-center py-2 px-4 bg-white rounded-lg',
'text-black font-medium',
'focus-visible:outline-white focus-visible:outline-offset-2'
),
className
)}
{...props}
@@ -109,9 +120,11 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
'relative isolate overflow-clip @container/media-root',
// Appearance
'rounded-[var(--media-border-radius,0.75rem)] bg-black',
'font-sans text-[0.8125rem] leading-normal subpixel-antialiased',
// Box-sizing reset for children
'**:box-border',
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
// Resets
'**:box-border **:m-0',
'[&_button]:font-[inherit]',
'motion-safe:[interpolate-size:allow-keywords]',
// Outer border ring (::after only)
'after:absolute after:pointer-events-none after:rounded-[inherit] after:z-10',
'after:inset-0 after:ring-1 after:ring-inset after:ring-black/15',
@@ -144,6 +157,38 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
}
/>
<ErrorDialog
aria-labelledby="media-error-title"
aria-describedby="media-error-description"
render={(props, { onDismiss }) => (
<div
{...props}
className="peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-[visible]:flex"
>
<div
className={cn(
'hidden flex-col gap-3 max-w-64 p-4 text-white text-sm pointer-events-auto',
'group-data-[visible]/error:flex',
'text-shadow-2xs text-shadow-black/50',
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
'starting:opacity-0 starting:scale-50',
'ease-[linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]'
)}
>
<div className="flex flex-col gap-2 py-1.5">
<p id="media-error-title" className="font-semibold leading-tight">
Something went wrong.
</p>
<p id="media-error-description">An error occurred while trying to play the video. Please try again.</p>
</div>
<div className="flex gap-2 *:flex-1">
<Button onClick={onDismiss}>OK</Button>
</div>
</div>
</div>
)}
/>
<Controls.Root
className={cn(
// Peer marker for overlay/captions
@@ -172,7 +217,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
<PlayButton
render={(props, state) => (
<Button {...props}>
<Button variant="icon" {...props}>
<PlayButtonIcon state={state} className={icon} />
</Button>
)}
@@ -181,10 +226,12 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button {...props} className="@max-md/media-controls:hidden">
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
<span className="relative">
<SeekIcon className={cn(icon, '[scale:-1_1]')} />
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">
{SEEK_TIME}
</span>
</span>
</Button>
)}
@@ -193,10 +240,12 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button {...props} className="@max-md/media-controls:hidden">
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
<span className="relative">
<SeekIcon className={icon} />
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">
{SEEK_TIME}
</span>
</span>
</Button>
)}
@@ -207,18 +256,12 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<Time.Group className="flex items-center gap-1">
<Time.Value
type="current"
className={cn(
'hidden tabular-nums drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
'@md/media-controls:inline'
)}
className={cn('hidden tabular-nums text-shadow-2xs text-shadow-black/25', '@md/media-controls:inline')}
/>
<Time.Separator className={cn('hidden', '@md/media-controls:inline @md/media-controls:text-white/50')} />
<Time.Value
type="duration"
className={cn(
'tabular-nums drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
'@md/media-controls:text-white/50'
)}
className={cn('tabular-nums text-shadow-2xs text-shadow-black/25', '@md/media-controls:text-white/50')}
/>
</Time.Group>
@@ -229,7 +272,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<span className={cn('flex items-center gap-[0.075rem]', '@2xl/media-root:gap-0.5')}>
<MuteButton
render={(props, state) => (
<Button {...props}>
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
</Button>
)}
@@ -237,7 +280,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<PiPButton
render={(props) => (
<Button {...props}>
<Button variant="icon" {...props}>
<PipIcon className={icon} />
</Button>
)}
@@ -245,7 +288,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
<FullscreenButton
render={(props, state) => (
<Button {...props}>
<Button variant="icon" {...props}>
<FullscreenButtonIcon state={state} className={icon} />
</Button>
)}
@@ -284,16 +327,24 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
// Layout
'absolute inset-0 flex flex-col items-start',
'pointer-events-none rounded-[inherit]',
// Gradient overlay (heavier gradient with positioned stop)
// Default: hidden
'opacity-0',
'bg-gradient-to-t from-black/70 via-black/50 via-[7.5rem] to-transparent',
'backdrop-blur-[0px] backdrop-saturate-150 backdrop-brightness-90',
// Transitions
'transition-opacity ease-out duration-75 delay-0',
// Hidden when controls hidden (peer sibling)
'peer-not-data-[visible]/controls:opacity-0',
'peer-not-data-[visible]/controls:delay-500',
'peer-not-data-[visible]/controls:duration-500',
'transition-[opacity,backdrop-filter] ease-out',
'duration-500 delay-500',
// Shown when controls visible
'peer-data-[visible]/controls:opacity-100',
'peer-data-[visible]/controls:duration-150',
'peer-data-[visible]/controls:delay-0',
// Shown when error visible (+ blur)
'peer-data-[visible]/error:opacity-100',
'peer-data-[visible]/error:duration-150',
'peer-data-[visible]/error:delay-0',
'peer-data-[visible]/error:backdrop-blur-[8px]',
// Reduced motion
'motion-reduce:peer-not-data-[visible]/controls:duration-100'
'motion-reduce:duration-100'
)}
/>
</Container>
@@ -17,6 +17,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
@@ -82,11 +83,33 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
}
/>
<ErrorDialog
aria-labelledby="media-error-title"
aria-describedby="media-error-description"
render={(props, { onDismiss }) => (
<div {...props} className="media-error">
<div className="media-error__dialog">
<div className="media-error__content">
<p id="media-error-title" className="media-error__title">
Something went wrong.
</p>
<p id="media-error-description" className="media-error__description">
An error occurred while trying to play the video. Please try again.
</p>
</div>
<div className="media-error__actions">
<Button onClick={onDismiss}>OK</Button>
</div>
</div>
</div>
)}
/>
<Controls.Root className="media-controls">
<span className="media-button-group">
<PlayButton
render={(props, state) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<PlayButtonIcon state={state} className="media-icon" />
</Button>
)}
@@ -95,7 +118,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--seek">
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek media-icon--flipped" />
<span className="media-icon__label">{SEEK_TIME}</span>
@@ -107,7 +130,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--seek">
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek" />
<span className="media-icon__label">{SEEK_TIME}</span>
@@ -125,13 +148,13 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
</Time.Group>
{/* Temporary spacer */}
<span className="media-slider" style={{ height: '4px', background: 'oklab(1 0 0 / 0.2)' }} />
<span className="media-slider" style={{ height: '4px', background: 'oklch(1 0 0 / 0.2)' }} />
</span>
<span className="media-button-group">
<MuteButton
render={(props, state) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
</Button>
)}
@@ -139,7 +162,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
<PiPButton
render={(props) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<PipIcon className="media-icon" />
</Button>
)}
@@ -147,7 +170,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
<FullscreenButton
render={(props, state) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<FullscreenButtonIcon state={state} className="media-icon" />
</Button>
)}
+313 -142
View File
@@ -1,64 +1,68 @@
/* ==========================================================================
Reset
========================================================================== */
.media-default-skin *,
.media-default-skin *::before,
.media-default-skin *::after {
box-sizing: border-box;
margin: 0;
}
.media-default-skin img,
.media-default-skin video,
.media-default-skin svg {
display: block;
max-width: 100%;
}
.media-default-skin button {
font: inherit;
}
@media (prefers-reduced-motion: no-preference) {
.media-default-skin {
interpolate-size: allow-keywords;
}
}
/* ==========================================================================
Root Container
========================================================================== */
.media-default-skin {
position: relative;
isolation: isolate;
container: media-root / inline-size;
overflow: clip;
border-radius: var(--media-border-radius, 2rem);
background: oklab(0 0 0);
font-family: ui-sans-serif, system-ui, sans-serif;
background: oklch(0 0 0);
font-family:
Inter Variable,
Inter,
ui-sans-serif,
system-ui,
sans-serif;
font-size: 0.8125rem;
line-height: 1.5;
letter-spacing: normal;
-webkit-font-smoothing: auto;
-moz-osx-font-smoothing: auto;
}
.media-default-skin * {
box-sizing: border-box;
}
/* Inner border rings */
.media-default-skin::before,
.media-default-skin::after {
content: "";
position: absolute;
pointer-events: none;
border-radius: inherit;
z-index: 10;
border-radius: inherit;
pointer-events: none;
}
.media-default-skin::before {
inset: 1px;
box-shadow: inset 0 0 0 1px oklab(1 0 0 / 0.15);
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
}
.media-default-skin::after {
inset: 0;
box-shadow: inset 0 0 0 1px oklab(0 0 0 / 0.1);
}
/* Buffering indicator */
.media-default-skin .media-buffering-indicator {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
color: #fff;
}
.media-default-skin .media-buffering-indicator .media-surface {
padding: 0.25rem;
border-radius: 100%;
}
/* Poster image */
.media-default-skin > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
transition: opacity 0.25s;
}
.media-default-skin > img:not([data-visible]) {
opacity: 0;
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.1);
}
/* Fullscreen */
@@ -66,111 +70,236 @@
border-radius: 0;
}
/* ==========================================================================
Media Element
========================================================================== */
.media-default-skin > video {
display: block;
width: 100%;
height: 100%;
}
/* Media Container UI Overlay Styling */
/* ==========================================================================
Poster Image
========================================================================== */
.media-default-skin > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.25s;
pointer-events: none;
}
.media-default-skin > img:not([data-visible]) {
opacity: 0;
}
/* ==========================================================================
Overlay / Scrim
========================================================================== */
.media-default-skin .media-overlay {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
background-image: linear-gradient(to top, oklab(0 0 0 / 0.5), oklab(0 0 0 / 0.3), rgba(0, 0, 0, 0));
backdrop-filter: saturate(1.5) brightness(0.9);
transition-delay: 500ms;
transition-duration: 300ms;
transition-behavior: allow-discrete;
transition-property: opacity, display;
transition-timing-function: ease-out;
background-image: linear-gradient(to top, oklch(0 0 0 / 0.5), oklch(0 0 0 / 0.3), oklch(0 0 0 / 0));
backdrop-filter: blur(0) saturate(1.5) brightness(0.9);
opacity: 0;
display: none;
transition-property: opacity, backdrop-filter;
transition-duration: 300ms;
transition-delay: 500ms;
transition-timing-function: ease-out;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-default-skin .media-overlay {
transition-duration: 100ms;
}
}
.media-default-skin .media-controls[data-visible] ~ .media-overlay {
.media-default-skin .media-controls[data-visible] ~ .media-overlay,
.media-default-skin .media-error[data-visible] ~ .media-overlay {
opacity: 1;
transition-duration: 150ms;
transition-delay: 0ms;
opacity: 1;
display: block;
}
.media-default-skin .media-error[data-visible] ~ .media-overlay {
backdrop-filter: blur(8px) saturate(1.5) brightness(0.9);
}
/* Common Surface Styles - e.g. tooltips, popovers, controls */
/* ==========================================================================
Buffering Indicator
========================================================================== */
.media-default-skin .media-buffering-indicator {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
color: oklch(1 0 0);
pointer-events: none;
}
.media-default-skin .media-buffering-indicator .media-surface {
padding: 0.25rem;
border-radius: 100%;
}
/* ==========================================================================
Error Dialog
========================================================================== */
.media-default-skin .media-error {
display: none;
}
.media-default-skin .media-error[data-visible] {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.media-default-skin .media-error__dialog {
display: none;
transition-property: display, opacity, transform;
transition-duration: 500ms;
transition-delay: 100ms;
transition-behavior: allow-discrete;
transition-timing-function: linear(
0,
0.034 1.5%,
0.763 9.7%,
1.066 13.9%,
1.198 19.9%,
1.184 21.8%,
0.963 37.5%,
0.997 50.9%,
1
);
}
.media-default-skin .media-error[data-visible] .media-error__dialog {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 18rem;
padding: 0.75rem;
border-radius: 1.75rem;
color: oklch(1 0 0);
font-size: 0.875rem;
pointer-events: auto;
transform: scale(1);
opacity: 1;
@starting-style {
transform: scale(0.5);
opacity: 0;
}
}
.media-default-skin .media-error__content {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.375rem;
}
.media-default-skin .media-error__title {
font-weight: 600;
line-height: 1.25;
}
.media-default-skin .media-error__description {
opacity: 0.7;
}
.media-default-skin .media-error__actions {
display: flex;
gap: 0.5rem;
}
.media-default-skin .media-error__actions > * {
flex: 1;
}
/* ==========================================================================
Surface (shared glass effect for tooltips, popovers, controls)
========================================================================== */
.media-default-skin .media-surface {
background-color: oklab(1 0 0 / 0.1);
background-color: oklch(1 0 0 / 0.1);
backdrop-filter: blur(64px) brightness(0.9) saturate(1.5);
box-shadow:
inset 0 0 0 1px oklab(1 0 0 / 0.05),
0 0 0 1px oklab(0 0 0 / 0.15),
oklab(0 0 0 / 0.15) 0px 1px 3px 0px,
oklab(0 0 0 / 0.15) 0px 1px 2px -1px;
inset 0 0 0 1px oklch(1 0 0 / 0.05),
0 0 0 1px oklch(0 0 0 / 0.15),
oklch(0 0 0 / 0.15) 0px 1px 3px 0px,
oklch(0 0 0 / 0.15) 0px 1px 2px -1px;
}
@media (prefers-reduced-transparency: reduce) {
.media-default-skin .media-surface {
background-color: oklab(0 0 0 / 0.7);
background-color: oklch(0 0 0 / 0.7);
box-shadow:
inset 0 0 0 1px oklab(0 0 0),
0 0 0 1px oklab(1 0 0 / 0.2);
inset 0 0 0 1px oklch(0 0 0),
0 0 0 1px oklch(1 0 0 / 0.2);
}
}
@media (prefers-contrast: more) {
.media-default-skin .media-surface {
background-color: oklab(0 0 0 / 0.9);
background-color: oklch(0 0 0 / 0.9);
box-shadow:
inset 0 0 0 1px oklab(0 0 0),
0 0 0 1px oklab(1 0 0 / 0.2);
inset 0 0 0 1px oklch(0 0 0),
0 0 0 1px oklch(1 0 0 / 0.2);
}
}
/* Media Control Bar UI/Styles */
/* ==========================================================================
Controls
========================================================================== */
.media-default-skin .media-controls {
position: absolute;
container: media-controls / inline-size;
bottom: 0.75rem;
inset-inline: 0.75rem;
padding: 0.175rem;
z-index: 10;
container: media-controls / inline-size;
display: flex;
align-items: center;
gap: 0.075rem;
color: oklab(1 0 0);
padding: 0.175rem;
border-radius: calc(infinity * 1px);
color: oklch(1 0 0);
will-change: scale, transform, filter, opacity;
transition-property: scale, transform, filter, opacity;
transition-timing-function: ease-out;
transition-delay: 0ms;
transition-duration: 100ms;
transition-delay: 0ms;
transition-timing-function: ease-out;
transform-origin: bottom;
z-index: 10;
}
.media-default-skin .media-controls:not([data-visible]) {
pointer-events: none;
filter: blur(8px);
scale: 0.9;
opacity: 0;
transition-delay: 500ms;
scale: 0.9;
filter: blur(8px);
transition-duration: 300ms;
transition-delay: 500ms;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.media-default-skin .media-controls:not([data-visible]) {
/* FIXME: Make this a custom property that we can share */
transition-duration: 100ms;
filter: blur(0);
scale: 1;
filter: blur(0);
transition-duration: 100ms;
}
}
@container media-root (width > 40rem) {
.media-default-skin .media-controls {
padding: 0.25rem;
gap: 0.125rem;
padding: 0.25rem;
}
}
/* Time Display */
/* ==========================================================================
Time Display
========================================================================== */
.media-default-skin .media-time {
container: media-time / inline-size;
display: flex;
@@ -180,8 +309,8 @@
padding-inline: 0.5rem;
}
.media-default-skin .media-time__value {
text-shadow: 0 1px 0 oklab(0 0 0 / 0.25);
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 0 oklch(0 0 0 / 0.25);
}
.media-default-skin .media-time .media-time__value:first-child {
display: none;
@@ -192,47 +321,70 @@
}
}
/* Buttons */
/* ==========================================================================
Buttons
========================================================================== */
/* Base button */
.media-default-skin .media-button {
display: grid;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.5rem;
cursor: pointer;
background: transparent;
padding: 0.5rem 1rem;
background: oklch(1 0 0);
border: none;
border-radius: calc(infinity * 1px);
color: oklab(1 0 0 / 0.9);
user-select: none;
outline: 2px solid transparent;
outline-offset: -2px;
color: oklch(0 0 0);
font-weight: 500;
transition-property: background-color, color, outline-offset;
transition-duration: 150ms;
transition-timing-function: ease-out;
text-shadow: 0 1px 0 oklab(0 0 0 / 0.25);
}
.media-default-skin .media-button:hover,
.media-default-skin .media-button:focus-visible,
.media-default-skin .media-button[aria-expanded="true"] {
background-color: oklab(1 0 0 / 0.1);
color: oklab(1 0 0);
text-decoration: none;
cursor: pointer;
user-select: none;
}
.media-default-skin .media-button:focus-visible {
outline-color: oklch(62.3% 0.214 259.815);
outline-offset: 2px;
}
.media-default-skin .media-button[disabled] {
cursor: not-allowed;
opacity: 0.5;
filter: grayscale(1);
cursor: not-allowed;
}
/* Icon button variant */
.media-default-skin .media-button--icon {
display: grid;
padding: 0.5rem;
background: transparent;
color: oklch(1 0 0 / 0.9);
text-shadow: 0 1px 0 oklch(0 0 0 / 0.25);
}
.media-default-skin .media-button--icon:hover,
.media-default-skin .media-button--icon:focus-visible,
.media-default-skin .media-button--icon[aria-expanded="true"] {
background-color: oklch(1 0 0 / 0.1);
color: oklch(1 0 0);
text-decoration: none;
}
.media-default-skin .media-button--icon .media-icon {
filter: drop-shadow(0 1px 0 oklch(0 0 0 / 0.25));
}
/* Seek button variant — hidden at small sizes */
@container media-controls (width < 28rem) {
.media-default-skin .media-button--seek {
display: none;
}
}
/* Icons */
/* ==========================================================================
Icons
========================================================================== */
.media-default-skin .media-icon__container {
position: relative;
}
@@ -240,7 +392,6 @@
grid-area: 1 / 1;
width: 18px;
height: 18px;
filter: drop-shadow(0 1px 0 oklab(0 0 0 / 0.25));
transition-behavior: allow-discrete;
transition-property: display, opacity;
transition-duration: 150ms;
@@ -253,87 +404,90 @@
.media-default-skin .media-icon--flipped {
scale: -1 1;
}
/* Seek icon label positioning */
.media-default-skin .media-icon--seek ~ .media-icon__label {
position: absolute;
right: 0;
bottom: -3px;
font-size: 0.75em;
font-weight: 480;
font-variant-numeric: tabular-nums;
}
.media-default-skin .media-icon--seek.media-icon--flipped ~ .media-icon__label {
left: 0;
right: unset;
left: 0;
}
/* TimeSlider Component Styles */
/* ==========================================================================
Slider
========================================================================== */
.media-default-skin .media-slider {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex: 1;
border-radius: calc(infinity * 1px);
outline: none;
}
/* Horizontal orientation styles */
/* Horizontal orientation */
.media-default-skin .media-slider[data-orientation="horizontal"] {
min-width: 5rem;
width: 100%;
height: 1.25rem;
}
/* Vertical orientation styles */
/* Vertical orientation */
.media-default-skin .media-slider[data-orientation="vertical"] {
height: 5rem;
width: 1.25rem;
height: 5rem;
}
/* Track */
.media-default-skin .media-slider__track {
position: relative;
isolation: isolate;
background-color: oklab(1 0 0 / 0.2);
border-radius: inherit;
overflow: hidden;
user-select: none;
background-color: oklch(1 0 0 / 0.2);
border-radius: inherit;
outline: 2px solid transparent;
outline-offset: -2px;
box-shadow: 0 0 0 1px oklch(0 0 0 / 0.05);
transition: outline-offset 150ms ease-out;
box-shadow: 0 0 0 1px oklab(0 0 0 / 0.05);
user-select: none;
}
/* Horizontal track styles */
.media-default-skin .media-slider__track[data-orientation="horizontal"] {
width: 100%;
height: 0.25rem;
}
/* Vertical track styles */
.media-default-skin .media-slider__track[data-orientation="vertical"] {
width: 0.25rem;
height: 100%;
}
.media-default-skin .media-slider:focus-visible .media-slider__track {
outline-color: oklch(62.3% 0.214 259.815);
outline-offset: 6px;
}
/* Thumb */
.media-default-skin .media-slider__thumb {
z-index: 10;
width: 0.625rem;
height: 0.625rem;
background-color: oklab(1 0 0);
background-color: oklch(1 0 0);
border-radius: calc(infinity * 1px);
user-select: none;
z-index: 10;
box-shadow:
0 0 0 1px oklab(0 0 0 / 0.1),
0 1px 3px 0 oklab(0 0 0 / 0.15),
0 1px 2px -1px oklab(0 0 0 / 0.15);
0 0 0 1px oklch(0 0 0 / 0.1),
0 1px 3px 0 oklch(0 0 0 / 0.15),
0 1px 2px -1px oklch(0 0 0 / 0.15);
opacity: 0;
transition-property: opacity, height, width;
transition-duration: 150ms;
transition-timing-function: ease-out;
user-select: none;
}
.media-default-skin .media-slider__thumb:active {
width: 0.75rem;
@@ -350,32 +504,39 @@
cursor: ns-resize;
}
/* Pointer (hover preview) */
.media-default-skin .media-slider__pointer {
background-color: oklab(1 0 0 / 0.2);
background-color: oklch(1 0 0 / 0.2);
border-radius: inherit;
}
/* Progress fill */
.media-default-skin .media-slider__progress {
background-color: oklab(1 0 0);
background-color: oklch(1 0 0);
border-radius: inherit;
}
/* Time display within slider */
.media-default-skin .media-slider__time-display {
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Popups & Animations
========================================================================== */
.media-default-skin .media-popup-animation {
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
opacity: 1;
transform: scale(1);
transform-origin: bottom;
opacity: 1;
filter: blur(0px);
transition-property: transform, scale, opacity, filter;
transition-duration: 200ms;
}
.media-default-skin .media-popup-animation[data-starting-style],
.media-default-skin .media-popup-animation[data-ending-style] {
transform: scale(0);
opacity: 0;
transform: scale(0);
filter: blur(8px);
}
.media-default-skin .media-popup-animation[data-instant] {
@@ -383,34 +544,40 @@
}
.media-default-skin .media-popover-popup {
padding: 0.75rem 0.25rem;
border-radius: calc(infinity * 1px);
margin: 0;
padding: 0.75rem 0.25rem;
border: 0;
border-radius: calc(infinity * 1px);
}
/* Tooltip Component Styles */
/* ==========================================================================
Tooltips
========================================================================== */
.media-default-skin .media-tooltip {
white-space: nowrap;
}
.media-default-skin .media-tooltip-popup {
color: oklab(1 0 0);
padding: 0.25rem 0.625rem;
border-radius: calc(infinity * 1px);
color: oklch(1 0 0);
font-size: 0.75rem;
}
/* Captions */
/* ==========================================================================
Captions
========================================================================== */
.media-default-skin .media-captions {
position: absolute;
inset: auto 1rem 1.5rem 1rem;
z-index: 20;
transition: transform 150ms ease-out;
font-size: 1rem;
text-wrap: balance;
/* The delay must account for the controls delay/duration */
transition: transform 150ms ease-out;
transition-delay: 600ms;
pointer-events: none;
text-wrap: balance;
font-size: 1rem;
}
@media (prefers-reduced-motion: reduce) {
.media-default-skin .media-captions {
@@ -418,34 +585,36 @@
}
}
.media-default-skin .media-captions__container {
max-width: 42ch;
margin: 0 auto;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
max-width: 42ch;
margin: 0 auto;
text-align: center;
}
.media-default-skin .media-captions__text {
display: block;
padding: 0.125rem 0.5rem;
color: oklch(1 0 0);
text-shadow:
0 0 1px oklab(0 0 0 / 0.7),
0 0 8px oklab(0 0 0 / 0.7);
color: #fff;
0 0 1px oklch(0 0 0 / 0.7),
0 0 8px oklch(0 0 0 / 0.7);
text-align: center;
white-space: pre-wrap;
line-height: 1.2;
}
@media (prefers-contrast: more) {
.media-default-skin .media-captions__text {
background: oklch(0 0 0 / 0.7);
text-shadow: none;
box-decoration-break: clone;
background: oklab(0 0 0 / 0.7);
}
}
.media-default-skin .media-captions__text > * {
display: inline;
}
/* Responsive caption sizing */
@container media-root (width > 20rem) {
.media-default-skin .media-captions {
font-size: 1.5rem;
@@ -461,6 +630,8 @@
font-size: 2.25rem;
}
}
/* Shift captions up when controls visible */
.media-default-skin .media-controls[data-visible] ~ .media-captions {
transform: translateY(-3rem);
transition-delay: 25ms;
@@ -17,6 +17,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
@@ -37,14 +38,14 @@ const surface = cn(
// Border to enhance contrast on lighter videos
'after:absolute after:inset-0 after:ring after:rounded-[inherit] after:ring-black/15 after:pointer-events-none after:z-10',
// Reduced transparency for users with preference
'[@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:ring-black [@media(prefers-reduced-transparency:reduce)]:after:ring-white/20',
'[@media(prefers-reduced-transparency:reduce)]:bg-black/70 [@media(prefers-reduced-transparency:reduce)]:ring-black [@media(prefers-reduced-transparency:reduce)]:after:ring-white/20',
// High contrast mode
'contrast-more:bg-black/90 contrast-more:ring-black contrast-more:after:ring-white/20'
);
const icon = cn(
'[grid-area:1/1] size-4.5 shrink-0',
'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
'drop-shadow-[0_1px_0_var(--tw-drop-shadow-color)] drop-shadow-black/25',
'transition-discrete transition-[display,opacity] duration-150 ease-out'
);
@@ -52,26 +53,36 @@ const iconHidden = 'hidden opacity-0';
/* --------------------------------------- Components ---------------------------------------- */
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button({ className, ...props }, ref) {
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
{ className, variant, ...props },
ref
) {
return (
<button
ref={ref}
type="button"
className={cn(
// Layout
'grid shrink-0 p-2 cursor-pointer bg-transparent border-none rounded-full',
'text-white/90 select-none',
// Shared
'shrink-0 border-none cursor-pointer select-none',
'outline-2 outline-transparent -outline-offset-2',
'drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25',
// Transitions
'transition-[background-color,color,outline-offset] duration-150 ease-out',
// Hover / focus / expanded
'hover:bg-white/10 hover:text-white hover:no-underline',
'focus-visible:bg-white/10 focus-visible:text-white',
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
'aria-expanded:bg-white/10 aria-expanded:text-white',
// Disabled
'disabled:cursor-not-allowed disabled:opacity-50 disabled:grayscale',
// Variant
variant === 'icon'
? cn(
'grid p-2 bg-transparent rounded-full',
'text-white/90',
'text-shadow-2xs text-shadow-black/25',
'hover:bg-white/10 hover:text-white hover:no-underline',
'focus-visible:bg-white/10 focus-visible:text-white',
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
'aria-expanded:bg-white/10 aria-expanded:text-white'
)
: cn(
'flex items-center justify-center py-2 px-4 bg-white rounded-full',
'text-black font-medium',
'focus-visible:outline-blue-500 focus-visible:outline-offset-2'
),
className
)}
{...props}
@@ -123,9 +134,11 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
'relative isolate overflow-clip @container/media-root',
// Appearance
'rounded-[var(--media-border-radius,2rem)] bg-black',
'font-sans text-[0.8125rem] leading-normal subpixel-antialiased',
// Box-sizing reset for children
'**:box-border',
'font-[Inter_Variable,Inter,ui-sans-serif,system-ui,sans-serif] text-[0.8125rem] leading-normal subpixel-antialiased',
// Resets
'**:box-border **:m-0',
'[&_button]:font-[inherit]',
'motion-safe:[interpolate-size:allow-keywords]',
// Inner highlight ring (::before)
'before:absolute before:pointer-events-none before:rounded-[inherit] before:z-10',
'before:inset-px before:ring-1 before:ring-inset before:ring-white/15',
@@ -162,6 +175,40 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
}
/>
<ErrorDialog
aria-labelledby="media-error-title"
aria-describedby="media-error-description"
render={(props, { onDismiss }) => (
<div
{...props}
className="peer/error group/error absolute inset-0 z-20 items-center justify-center pointer-events-none hidden data-[visible]:flex"
>
<div
className={cn(
'hidden flex-col gap-3 max-w-72 p-3 rounded-[1.75rem] text-white text-sm pointer-events-auto',
'group-data-[visible]/error:flex',
'transition-[display,opacity,scale,transform] duration-500 delay-100 transition-discrete',
'starting:opacity-0 starting:scale-50',
'ease-[linear(0,0.034_1.5%,0.763_9.7%,1.066_13.9%,1.198_19.9%,1.184_21.8%,0.963_37.5%,0.997_50.9%,1)]',
surface
)}
>
<div className="flex flex-col gap-2 px-2 pt-2 pb-1.5">
<p id="media-error-title" className="font-semibold leading-tight">
Something went wrong.
</p>
<p id="media-error-description" className="opacity-70">
An error occurred while trying to play the video. Please try again.
</p>
</div>
<div className="flex gap-2 *:flex-1">
<Button onClick={onDismiss}>OK</Button>
</div>
</div>
</div>
)}
/>
<Controls.Root
className={cn(
// Peer marker for overlay/captions
@@ -190,7 +237,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
>
<PlayButton
render={(props, state) => (
<Button {...props}>
<Button variant="icon" {...props}>
<PlayButtonIcon state={state} className={icon} />
</Button>
)}
@@ -199,10 +246,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button {...props} className="@max-md/media-controls:hidden">
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
<span className="relative">
<SeekIcon className={cn(icon, '[scale:-1_1]')} />
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
<span className="absolute left-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">{SEEK_TIME}</span>
</span>
</Button>
)}
@@ -211,10 +258,10 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button {...props} className="@max-md/media-controls:hidden">
<Button variant="icon" {...props} className="@max-md/media-controls:hidden">
<span className="relative">
<SeekIcon className={icon} />
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480]">{SEEK_TIME}</span>
<span className="absolute right-0 -bottom-0.75 text-[0.75em] font-[480] tabular-nums">{SEEK_TIME}</span>
</span>
</Button>
)}
@@ -223,19 +270,16 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<Time.Group className="@container/media-time flex items-center flex-1 gap-3 px-2">
<Time.Value
type="current"
className="hidden @2xs/media-time:block drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25 tabular-nums"
className="hidden @2xs/media-time:block text-shadow-2xs text-shadow-black/25 tabular-nums"
/>
{/* Temporary spacer */}
<div className="flex-1 h-1 rounded-full bg-white/20" />
<Time.Value
type="duration"
className="drop-shadow-[0_1px_0_var(--tw-shadow-color)] shadow-black/25 tabular-nums"
/>
<Time.Value type="duration" className="text-shadow-2xs text-shadow-black/25 tabular-nums" />
</Time.Group>
<MuteButton
render={(props, state) => (
<Button {...props}>
<Button variant="icon" {...props}>
<MuteButtonIcon state={state} className={icon} />
</Button>
)}
@@ -243,7 +287,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<PiPButton
render={(props) => (
<Button {...props}>
<Button variant="icon" {...props}>
<PipIcon className={icon} />
</Button>
)}
@@ -251,7 +295,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
<FullscreenButton
render={(props, state) => (
<Button {...props}>
<Button variant="icon" {...props}>
<FullscreenButtonIcon state={state} className={icon} />
</Button>
)}
@@ -289,17 +333,24 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
// Layout
'absolute inset-0 flex flex-col items-start',
'pointer-events-none rounded-[inherit]',
// Gradient overlay
// Default: hidden
'opacity-0',
'bg-gradient-to-t from-black/50 via-black/30 to-transparent',
'backdrop-saturate-150 backdrop-brightness-90',
'backdrop-blur-[0px] backdrop-saturate-150 backdrop-brightness-90',
// Transitions
'transition-opacity ease-out duration-100 delay-0',
// Hidden when controls hidden (peer sibling)
'peer-not-data-[visible]/controls:opacity-0',
'peer-not-data-[visible]/controls:delay-500',
'peer-not-data-[visible]/controls:duration-300',
'transition-[opacity,backdrop-filter] ease-out',
'duration-300 delay-500',
// Shown when controls visible
'peer-data-[visible]/controls:opacity-100',
'peer-data-[visible]/controls:duration-150',
'peer-data-[visible]/controls:delay-0',
// Shown when error visible (+ blur)
'peer-data-[visible]/error:opacity-100',
'peer-data-[visible]/error:duration-150',
'peer-data-[visible]/error:delay-0',
'peer-data-[visible]/error:backdrop-blur-[8px]',
// Reduced motion
'motion-reduce:peer-not-data-[visible]/controls:duration-100'
'motion-reduce:duration-100'
)}
/>
</Container>
+31 -8
View File
@@ -17,6 +17,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { BufferingIndicator } from '@/ui/buffering-indicator';
import { Controls } from '@/ui/controls';
import { ErrorDialog } from '@/ui/error-dialog';
import { FullscreenButton } from '@/ui/fullscreen-button';
import { MuteButton } from '@/ui/mute-button';
import { PiPButton } from '@/ui/pip-button';
@@ -84,10 +85,32 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
}
/>
<ErrorDialog
aria-labelledby="media-error-title"
aria-describedby="media-error-description"
render={(props, { onDismiss }) => (
<div {...props} className="media-error">
<div className="media-error__dialog media-surface">
<div className="media-error__content">
<p id="media-error-title" className="media-error__title">
Something went wrong.
</p>
<p id="media-error-description" className="media-error__description">
An error occurred while trying to play the video. Please try again.
</p>
</div>
<div className="media-error__actions">
<Button onClick={onDismiss}>OK</Button>
</div>
</div>
</div>
)}
/>
<Controls.Root className="media-surface media-controls">
<PlayButton
render={(props, state) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<PlayButtonIcon state={state} className="media-icon" />
</Button>
)}
@@ -96,7 +119,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--seek">
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek media-icon--flipped" />
<span className="media-icon__label">{SEEK_TIME}</span>
@@ -108,7 +131,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--seek">
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek" />
<span className="media-icon__label">{SEEK_TIME}</span>
@@ -126,7 +149,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<MuteButton
render={(props, state) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<MuteButtonIcon state={state} className="media-icon" />
</Button>
)}
@@ -134,7 +157,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<PiPButton
render={(props) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<PipIcon className="media-icon" />
</Button>
)}
@@ -142,21 +165,21 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
<FullscreenButton
render={(props, state) => (
<Button {...props}>
<Button {...props} className="media-button--icon">
<FullscreenButtonIcon state={state} className="media-icon" />
</Button>
)}
/>
</Controls.Root>
{/*<div className="media-captions">
{/* <div className="media-captions">
<div className="media-captions__container">
<span className="media-captions__text">An example cue</span>
<span className="media-captions__text">
<p>Another example cue with HTML</p>
</span>
</div>
</div>*/}
</div> */}
<div className="media-overlay" />
</Container>
@@ -0,0 +1,83 @@
'use client';
import type { ForwardedRef } from 'react';
import { forwardRef, useEffect, useRef, useState } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
type ErrorDialogState = { visible: boolean; onDismiss: () => void };
export interface ErrorDialogProps extends UIComponentProps<'div', ErrorDialogState> {
'aria-labelledby': string;
'aria-describedby'?: string;
dismiss?: () => void;
}
const DEBUG = false;
/**
* Displays an error dialog when media playback encounters an error.
*
* @example
* ```tsx
* <ErrorDialog />
*
* <ErrorDialog
* render={(props, state) => (
* <div {...props}>Something went wrong</div>
* )}
* />
* ```
*/
export const ErrorDialog = forwardRef(function ErrorDialog(
componentProps: ErrorDialogProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, ...elementProps } = componentProps;
const dialogRef = useRef<HTMLDivElement>(null);
const [hasError, setHasError] = useState(false);
// FIXME: This is just debugging code
useEffect(() => {
const handle = setTimeout(() => {
if (!DEBUG) return;
setHasError(true);
}, 2000);
return () => clearTimeout(handle);
}, []);
// Focus the dialog when it becomes visible to ensure it's announced by screen readers.
// Wait a frame for the accessibility tree to update with the dialog before focusing.
useEffect(() => {
if (!hasError) return;
requestAnimationFrame(() => {
dialogRef.current?.focus();
});
}, [hasError]);
return renderElement(
'div',
{ render, className, style },
{
state: {
visible: hasError,
onDismiss: () => setHasError(false),
},
ref: [forwardedRef, dialogRef],
props: [
{
role: 'alertdialog',
tabIndex: -1,
},
elementProps,
],
}
);
});
export namespace ErrorDialog {
export type Props = ErrorDialogProps;
export type State = ErrorDialogState;
}
@@ -0,0 +1 @@
export * from './error-dialog';
+1 -1
View File
@@ -72,7 +72,7 @@ export function renderElement<
element: TagName,
componentProps: UseRenderComponentProps<State>,
params: UseRenderParameters<State, RenderedElementType>
): ReactElement {
): ReactElement | null {
const { className: classNameProp, style: styleProp, render } = componentProps;
const { state, ref, props, stateAttrMap } = params;