diff --git a/packages/react/src/components/layouts/default/audio-layout.tsx b/packages/react/src/components/layouts/default/audio-layout.tsx index f68082cfd..5016e8578 100644 --- a/packages/react/src/components/layouts/default/audio-layout.tsx +++ b/packages/react/src/components/layouts/default/audio-layout.tsx @@ -4,13 +4,7 @@ import { useSignal } from 'maverick.js/react'; import { listenEvent, toggleClass } from 'maverick.js/std'; import { useChapterTitle } from '../../../hooks/use-chapter-title'; -import { - useActive, - useMouseEnter, - useRectCSSVars, - useResizeObserver, - useTransitionActive, -} from '../../../hooks/use-dom'; +import { useResizeObserver, useTransitionActive } from '../../../hooks/use-dom'; import { useMediaContext } from '../../../hooks/use-media-context'; import { useMediaState } from '../../../hooks/use-media-state'; import { createComputed } from '../../../hooks/use-signals'; @@ -26,17 +20,12 @@ import { type Slots, } from './slots'; import { DefaultAnnouncer } from './ui/announcer'; -import { - DefaultCaptionButton, - DefaultMuteButton, - DefaultPlayButton, - DefaultSeekButton, -} from './ui/buttons'; +import { DefaultCaptionButton, DefaultPlayButton, DefaultSeekButton } from './ui/buttons'; import { DefaultCaptions } from './ui/captions'; import { DefaultControlsSpacer } from './ui/controls'; import { DefaultChaptersMenu } from './ui/menus/chapters-menu'; import { DefaultSettingsMenu } from './ui/menus/settings-menu'; -import { DefaultTimeSlider, DefaultVolumeSlider } from './ui/sliders'; +import { DefaultTimeSlider, DefaultVolumePopup } from './ui/sliders'; import { DefaultTimeInvert } from './ui/time'; /* ------------------------------------------------------------------------------------------------- @@ -121,7 +110,7 @@ function AudioLayout() { {slot(slots, 'timeSlider', )} - + {slot(slots, 'captionButton', )} @@ -231,32 +220,3 @@ function AudioTitle({ title, chapterTitle }: { title: string; chapterTitle: stri } AudioTitle.displayName = 'AudioTitle'; - -/* ------------------------------------------------------------------------------------------------- - * DefaultAudioVolume - * -----------------------------------------------------------------------------------------------*/ - -function DefaultAudioVolume() { - const $pointer = useMediaState('pointer'), - $muted = useMediaState('muted'), - [rootEl, setRootEl] = React.useState(null), - [triggerEl, setTriggerEl] = React.useState(null), - [popperEl, setPopperEl] = React.useState(null), - isRootActive = useActive(rootEl), - hasMouseEnteredTrigger = useMouseEnter(triggerEl), - slots = useDefaultAudioLayoutSlots(); - - useRectCSSVars(rootEl, hasMouseEnteredTrigger ? triggerEl : null, 'trigger'); - useRectCSSVars(rootEl, hasMouseEnteredTrigger ? popperEl : null, 'popper'); - - return $pointer === 'coarse' && !$muted ? null : ( -
- {slot(slots, 'muteButton', )} -
- {slot(slots, 'volumeSlider', )} -
-
- ); -} - -DefaultAudioVolume.displayName = 'DefaultAudioVolume'; diff --git a/packages/react/src/components/layouts/default/ui/sliders.tsx b/packages/react/src/components/layouts/default/ui/sliders.tsx index 3f66959d4..e693a88e7 100644 --- a/packages/react/src/components/layouts/default/ui/sliders.tsx +++ b/packages/react/src/components/layouts/default/ui/sliders.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { useSignal } from 'maverick.js/react'; +import type { SliderOrientation } from 'vidstack'; -import { useResizeObserver } from '../../../../hooks/use-dom'; +import { useActive, useResizeObserver } from '../../../../hooks/use-dom'; import { useMediaState } from '../../../../hooks/use-media-state'; import { isRemotionSource } from '../../../../providers/remotion/type-check'; import type { TimeSliderInstance } from '../../../primitives/instances'; @@ -10,6 +11,36 @@ import * as TimeSlider from '../../../ui/sliders/time-slider'; import * as VolumeSlider from '../../../ui/sliders/volume-slider'; import { RemotionSliderThumbnail } from '../../remotion-ui'; import { useDefaultLayoutContext, useDefaultLayoutWord } from '../context'; +import { slot, type DefaultLayoutSlots } from '../slots'; +import { DefaultMuteButton } from './buttons'; + +/* ------------------------------------------------------------------------------------------------- + * DefaultVolumePopup + * -----------------------------------------------------------------------------------------------*/ + +export interface DefaultVolumePopupProps { + slots?: DefaultLayoutSlots; + orientation: SliderOrientation; +} + +function DefaultVolumePopup({ orientation, slots }: DefaultVolumePopupProps) { + const $pointer = useMediaState('pointer'), + $muted = useMediaState('muted'), + [rootEl, setRootEl] = React.useState(null), + isRootActive = useActive(rootEl); + + return $pointer === 'coarse' && !$muted ? null : ( +
+ {slot(slots, 'muteButton', )} +
+ {slot(slots, 'volumeSlider', )} +
+
+ ); +} + +DefaultVolumePopup.displayName = 'DefaultVolumePopup'; +export { DefaultVolumePopup }; /* ------------------------------------------------------------------------------------------------- * DefaultVolumeSlider diff --git a/packages/react/src/components/layouts/default/video-layout.tsx b/packages/react/src/components/layouts/default/video-layout.tsx index 91e361d97..cbc66e943 100644 --- a/packages/react/src/components/layouts/default/video-layout.tsx +++ b/packages/react/src/components/layouts/default/video-layout.tsx @@ -32,7 +32,7 @@ import { DefaultControlsSpacer } from './ui/controls'; import { DefaultKeyboardDisplay } from './ui/keyboard-display'; import { DefaultChaptersMenu } from './ui/menus/chapters-menu'; import { DefaultSettingsMenu } from './ui/menus/settings-menu'; -import { DefaultTimeSlider, DefaultVolumeSlider } from './ui/sliders'; +import { DefaultTimeSlider, DefaultVolumePopup } from './ui/sliders'; import { DefaultTimeInfo } from './ui/time'; import { DefaultChapterTitle } from './ui/title'; @@ -126,8 +126,7 @@ function DefaultVideoLargeLayout() { {slot(slots, 'playButton', )} - {slot(slots, 'muteButton', )} - {slot(slots, 'volumeSlider', )} + {slot(slots, 'chapterTitle', )} {slot(slots, 'captionButton', )} diff --git a/packages/react/src/hooks/use-dom.ts b/packages/react/src/hooks/use-dom.ts index 5fb6c93ce..16acbe1fc 100644 --- a/packages/react/src/hooks/use-dom.ts +++ b/packages/react/src/hooks/use-dom.ts @@ -48,7 +48,7 @@ export function useTransitionActive(el: Element | null) { } export function useMouseEnter(el: Element | null) { - const [hasEntered, setHasEntered] = React.useState(false); + const [isMouseEnter, setIsMouseEnter] = React.useState(false); React.useEffect(() => { if (!el) return; @@ -56,14 +56,14 @@ export function useMouseEnter(el: Element | null) { const disposal = createDisposalBin(); disposal.add( - listenEvent(el, 'mouseenter', () => setHasEntered(true)), - listenEvent(el, 'mouseleave', () => setHasEntered(false)), + listenEvent(el, 'mouseenter', () => setIsMouseEnter(true)), + listenEvent(el, 'mouseleave', () => setIsMouseEnter(false)), ); return () => disposal.empty(); }, [el]); - return hasEntered; + return isMouseEnter; } export function useFocusIn(el: Element | null) { @@ -86,10 +86,14 @@ export function useFocusIn(el: Element | null) { } export function useActive(el: Element | null) { - const hasMouseEntered = useMouseEnter(el), - isFocusIn = useFocusIn(el); + const isMouseEnter = useMouseEnter(el), + isFocusIn = useFocusIn(el), + prevMouseEnter = React.useRef(false); - return hasMouseEntered || isFocusIn; + if (prevMouseEnter.current && !isMouseEnter) return false; + + prevMouseEnter.current = isMouseEnter; + return isMouseEnter || isFocusIn; } export function useRectCSSVars(root: Element | null, el: Element | null, prefix: string) { diff --git a/packages/vidstack/player/styles/default/layouts/audio.css b/packages/vidstack/player/styles/default/layouts/audio.css index b4475e819..9183d009f 100644 --- a/packages/vidstack/player/styles/default/layouts/audio.css +++ b/packages/vidstack/player/styles/default/layouts/audio.css @@ -302,6 +302,7 @@ :where(.vds-audio-layout .vds-volume) { --media-slider-height: var(--audio-volume-height, 96px); --media-slider-preview-offset: 6px; + --gap: var(--audio-volume-gap, 16px); position: relative; display: flex; align-items: center; @@ -310,7 +311,7 @@ :where(.vds-audio-layout .vds-volume-popup) { position: absolute; - bottom: calc(100% + 16px); + bottom: calc(100% + var(--gap)); left: 50%; opacity: 0; transform: translateX(-50%); @@ -324,29 +325,23 @@ visibility: hidden; } -@media (prefers-color-scheme: light) { - :where(.vds-audio-layout .vds-volume-popup) { - border: var(--audio-volume-border, 1px solid rgb(10 10 10 / 0.1)); - background-color: var(--audio-volume-bg, var(--media-menu-bg, rgb(250 250 250))); - } -} - -/* safe triangle. */ -.vds-audio-layout .vds-volume::after { +/* safe area. */ +.vds-audio-layout .vds-volume-popup::after { content: ''; position: fixed; - top: 0; + bottom: calc(-1 * var(--gap)); right: 0; - bottom: 0; - left: 0; + width: 100%; + height: var(--gap); z-index: 1; pointer-events: auto; - clip-path: polygon( - var(--trigger-left, 0) var(--trigger-top, 0), - var(--popper-left, 0) var(--popper-bottom, 0), - var(--popper-right, 0) var(--popper-bottom, 0), - var(--trigger-right, 0) var(--trigger-top, 0) - ); +} + +@media (prefers-color-scheme: light) { + :where(.vds-audio-layout .vds-volume-popup) { + border: var(--audio-volume-border, 1px solid rgb(10 10 10 / 0.1)); + background-color: var(--audio-volume-bg, var(--media-menu-bg, rgb(250 250 250))); + } } :where(.vds-audio-layout .vds-volume[data-active] .vds-volume-popup), diff --git a/packages/vidstack/player/styles/default/layouts/video.css b/packages/vidstack/player/styles/default/layouts/video.css index 3f0ea6d4a..419035272 100644 --- a/packages/vidstack/player/styles/default/layouts/video.css +++ b/packages/vidstack/player/styles/default/layouts/video.css @@ -139,34 +139,46 @@ text-shadow: unset; } +/* + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * Volume Slider + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +:where(.vds-video-layout .vds-volume) { + --gap: var(--video-volume-gap, 10px); + display: contents; +} + +:where(.vds-video-layout .vds-volume-popup) { + display: contents; +} + :where(.vds-video-layout .vds-volume-slider) { + width: 100%; margin: 0; max-width: 0; transition: all 0.15s ease; - transform: translateX(-2px); } -:where(.vds-video-layout [data-media-mute-button][data-hocus] + .vds-volume-slider), -:where( - .vds-video-layout - [data-media-mute-button][data-hocus] - + .vds-tooltip-content - + .vds-volume-slider - ), -:where(.vds-video-layout [data-media-mute-tooltip][data-hocus] + .vds-volume-slider), -:where(.vds-video-layout .vds-volume-slider[data-active]) { - margin-left: 9px; +:where(.vds-video-layout .vds-volume[data-active] .vds-volume-slider), +:where(.vds-video-layout .vds-volume:has([data-active]) .vds-volume-slider) { + margin-left: var(--gap); + opacity: 1; + visibility: visible; max-width: var(--video-volume-slider-max-width, 72px); } -:where(.vds-video-layout .vds-volume-slider .vds-slider-value) { - bottom: 70px; -} - -@media (orientation: landscape) and (pointer: coarse) { - :where(.vds-video-layout .vds-volume-slider) { - display: none; - } +/* safe area. */ +.vds-video-layout .vds-volume-slider::after { + content: ''; + position: fixed; + top: 0; + left: calc(-1 * var(--gap)); + width: var(--gap); + height: 100%; + z-index: 1; + pointer-events: auto; } /* diff --git a/packages/vidstack/src/components/ui/menu/menu.ts b/packages/vidstack/src/components/ui/menu/menu.ts index 5a7525aef..e49cdb0fc 100644 --- a/packages/vidstack/src/components/ui/menu/menu.ts +++ b/packages/vidstack/src/components/ui/menu/menu.ts @@ -24,7 +24,13 @@ import { import { useMediaContext, type MediaContext } from '../../../core/api/media-context'; import type { MediaRequestEvents } from '../../../core/api/media-request-events'; import { $ariaBool } from '../../../utils/aria'; -import { isElementParent, isEventInside, onPress, setAttributeIfEmpty } from '../../../utils/dom'; +import { + isElementParent, + isElementVisible, + isEventInside, + onPress, + setAttributeIfEmpty, +} from '../../../utils/dom'; import { Popper } from '../popper/popper'; import { sliderObserverContext } from '../sliders/slider/slider-context'; import type { MenuButton } from './menu-button'; @@ -515,8 +521,8 @@ export class Menu extends Component { } else if (child.nodeType === 3) { height += parseFloat(getComputedStyle(child).fontSize); } else if (child instanceof HTMLElement) { + if (!isElementVisible(child)) continue; const style = getComputedStyle(child); - if (style.display === 'none') continue; height += child.offsetHeight + (parseFloat(style.marginTop) || 0) + diff --git a/packages/vidstack/src/components/ui/sliders/slider-preview.ts b/packages/vidstack/src/components/ui/sliders/slider-preview.ts index 0b32a3518..28cde63bd 100644 --- a/packages/vidstack/src/components/ui/sliders/slider-preview.ts +++ b/packages/vidstack/src/components/ui/sliders/slider-preview.ts @@ -56,9 +56,11 @@ export class SliderPreview extends Component { if (_disabled()) return; - const el = this.el!, + const el = this.el, { offset, noClamp } = this.$props; + if (!el) return; + updateSliderPreviewPlacement(el, { clamp: !noClamp(), offset: offset(), diff --git a/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts b/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts index 751ee1202..a229b164e 100644 --- a/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/audio-layout.ts @@ -6,26 +6,15 @@ import { toggleClass } from 'maverick.js/std'; import { useDefaultLayoutContext } from '../../../../components/layouts/default/context'; import { i18n } from '../../../../components/layouts/default/translations'; import { useMediaContext, useMediaState } from '../../../../core/api/media-context'; -import { - useActive, - useMouseEnter, - useRectCSSVars, - useResizeObserver, - useTransitionActive, -} from '../../../../utils/dom'; +import { useResizeObserver, useTransitionActive } from '../../../../utils/dom'; import { $signal } from '../../../lit/directives/signal'; import { DefaultAnnouncer } from './ui/announcer'; -import { - DefaultCaptionButton, - DefaultMuteButton, - DefaultPlayButton, - DefaultSeekButton, -} from './ui/buttons'; +import { DefaultCaptionButton, DefaultPlayButton, DefaultSeekButton } from './ui/buttons'; import { DefaultCaptions } from './ui/captions'; import { DefaultControlsSpacer } from './ui/controls'; import { DefaultChaptersMenu } from './ui/menu/chapters-menu'; import { DefaultSettingsMenu } from './ui/menu/settings-menu'; -import { DefaultTimeSlider, DefaultVolumeSlider } from './ui/slider'; +import { DefaultTimeSlider, DefaultVolumePopup } from './ui/slider'; import { DefaultTimeInvert } from './ui/time'; import { DefaultChapterTitle } from './ui/title'; @@ -43,7 +32,7 @@ export function DefaultAudioLayout() { DefaultAudioTitle(), DefaultTimeSlider(), DefaultTimeInvert(), - DefaultAudioVolume(), + DefaultVolumePopup({ orientation: 'vertical' }), DefaultCaptionButton({ tooltip: 'top' }), DefaultAudioMenus(), ]} @@ -107,35 +96,6 @@ function DefaultAudioTitle() { }); } -function DefaultAudioVolume() { - return $signal(() => { - const { pointer, muted } = useMediaState(); - - if (pointer() === 'coarse' && !muted()) return null; - - const $rootRef = signal(undefined), - $triggerRef = signal(undefined), - $popperRef = signal(undefined), - $isRootActive = useActive($rootRef), - $hasMouseEnteredTrigger = useMouseEnter($triggerRef); - - effect(() => { - if (!$hasMouseEnteredTrigger()) return; - useRectCSSVars($rootRef, $triggerRef, 'trigger'); - useRectCSSVars($rootRef, $popperRef, 'popper'); - }); - - return html` -
- ${DefaultMuteButton({ tooltip: 'top', ref: $triggerRef.set })} -
- ${DefaultVolumeSlider({ orientation: 'vertical' })} -
-
- `; - }); -} - function DefaultAudioMenus() { const placement = 'top end'; return [ diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts b/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts index 092c90836..8ac74ef40 100644 --- a/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts +++ b/packages/vidstack/src/elements/define/layouts/default/ui/slider.ts @@ -5,10 +5,30 @@ import { signal } from 'maverick.js'; import { useDefaultLayoutContext } from '../../../../../components/layouts/default/context'; import type { SliderOrientation } from '../../../../../components/ui/sliders/slider/types'; -import { useResizeObserver } from '../../../../../utils/dom'; +import { useMediaState } from '../../../../../core/api/media-context'; +import { useActive, useResizeObserver } from '../../../../../utils/dom'; import { $signal } from '../../../../lit/directives/signal'; +import { DefaultMuteButton } from './buttons'; import { $i18n } from './utils'; +export function DefaultVolumePopup({ orientation }: { orientation: SliderOrientation }) { + return $signal(() => { + const { pointer, muted } = useMediaState(); + + if (pointer() === 'coarse' && !muted()) return null; + + const $rootRef = signal(undefined), + $isRootActive = useActive($rootRef); + + return html` +
+ ${DefaultMuteButton({ tooltip: 'top' })} +
${DefaultVolumeSlider({ orientation })}
+
+ `; + }); +} + export function DefaultVolumeSlider({ orientation }: { orientation?: SliderOrientation } = {}) { const { translations } = useDefaultLayoutContext(), $label = $i18n(translations, 'Volume'); diff --git a/packages/vidstack/src/elements/define/layouts/default/video-layout.ts b/packages/vidstack/src/elements/define/layouts/default/video-layout.ts index a87218bd8..c8e22b640 100644 --- a/packages/vidstack/src/elements/define/layouts/default/video-layout.ts +++ b/packages/vidstack/src/elements/define/layouts/default/video-layout.ts @@ -19,7 +19,7 @@ import { DefaultControlsSpacer } from './ui/controls'; import { DefaultKeyboardDisplay } from './ui/keyboard-display'; import { DefaultChaptersMenu } from './ui/menu/chapters-menu'; import { DefaultSettingsMenu } from './ui/menu/settings-menu'; -import { DefaultTimeSlider, DefaultVolumeSlider } from './ui/slider'; +import { DefaultTimeSlider, DefaultVolumePopup } from './ui/slider'; import { DefaultTimeInfo } from './ui/time'; import { DefaultChapterTitle } from './ui/title'; @@ -47,8 +47,7 @@ export function DefaultVideoLayoutLarge() { ${[ DefaultPlayButton({ tooltip: 'top start' }), - DefaultMuteButton({ tooltip: 'top' }), - DefaultVolumeSlider(), + DefaultVolumePopup({ orientation: 'horizontal' }), DefaultTimeInfo(), DefaultChapterTitle(), DefaultCaptionButton({ tooltip: 'top' }), diff --git a/packages/vidstack/src/providers/video/remote-playback.ts b/packages/vidstack/src/providers/video/remote-playback.ts index 4ab2b8e2a..596afaf59 100644 --- a/packages/vidstack/src/providers/video/remote-playback.ts +++ b/packages/vidstack/src/providers/video/remote-playback.ts @@ -24,7 +24,7 @@ export abstract class VideoRemotePlaybackAdapter implements MediaRemotePlaybackA } private _setup() { - if (__SERVER__ || !this._video.remote || !this._canPrompt) return; + if (__SERVER__ || !this._video?.remote || !this._canPrompt) return; this._video.remote .watchAvailability((available) => { diff --git a/packages/vidstack/src/utils/dom.ts b/packages/vidstack/src/utils/dom.ts index efa6c3007..34a6fccef 100644 --- a/packages/vidstack/src/utils/dom.ts +++ b/packages/vidstack/src/utils/dom.ts @@ -24,6 +24,8 @@ import { setStyle, } from 'maverick.js/std'; +import { round } from './number'; + export interface EventTargetLike { addEventListener(type: string, handler: (...args: any[]) => void): void; removeEventListener(type: string, handler: (...args: any[]) => void): void; @@ -73,6 +75,11 @@ export function hasParentElement(node: Element | null, test: (node: Element) => return hasParentElement(node.parentElement, test); } +export function isElementVisible(el: HTMLElement) { + const style = getComputedStyle(el); + return style.display !== 'none' && parseInt(style.opacity) > 0; +} + export function isElementParent( owner: Element, node: Element | null, @@ -296,49 +303,56 @@ export function setRectCSSVars(root: Element, el: Element, prefix: string) { const rect = el.getBoundingClientRect(); for (const side of ['top', 'left', 'bottom', 'right']) { - setStyle(root as HTMLElement, `--${prefix}-${side}`, `${rect[side]}px`); + setStyle(root as HTMLElement, `--${prefix}-${side}`, `${round(rect[side], 3)}px`); } } export function useActive($el: ReadSignal) { - const $mouseEnter = useMouseEnter($el), - $focusIn = useFocusIn($el); + const $isMouseEnter = useMouseEnter($el), + $isFocusedIn = useFocusIn($el); - return computed(() => $mouseEnter() || $focusIn()); + let prevMouseEnter = false; + + return computed(() => { + const isMouseEnter = $isMouseEnter(); + if (prevMouseEnter && !isMouseEnter) return false; + prevMouseEnter = isMouseEnter; + return isMouseEnter || $isFocusedIn(); + }); } export function useMouseEnter($el: ReadSignal) { - const $enter = signal(false); + const $isMouseEnter = signal(false); effect(() => { const el = $el(); if (!el) { - $enter.set(false); + $isMouseEnter.set(false); return; } - listenEvent(el, 'mouseenter', () => $enter.set(true)); - listenEvent(el, 'mouseleave', () => $enter.set(false)); + listenEvent(el, 'mouseenter', () => $isMouseEnter.set(true)); + listenEvent(el, 'mouseleave', () => $isMouseEnter.set(false)); }); - return $enter; + return $isMouseEnter; } export function useFocusIn($el: ReadSignal) { - const $focusIn = signal(false); + const $isFocusIn = signal(false); effect(() => { const el = $el(); if (!el) { - $focusIn.set(false); + $isFocusIn.set(false); return; } - listenEvent(el, 'focusin', () => $focusIn.set(true)); - listenEvent(el, 'focusout', () => $focusIn.set(false)); + listenEvent(el, 'focusin', () => $isFocusIn.set(true)); + listenEvent(el, 'focusout', () => $isFocusIn.set(false)); }); - return $focusIn; + return $isFocusIn; }