diff --git a/examples/market/public/example.mp4 b/examples/market/public/example.mp4 new file mode 100644 index 00000000..3ce062e7 Binary files /dev/null and b/examples/market/public/example.mp4 differ diff --git a/examples/market/src/App.tsx b/examples/market/src/App.tsx index b10b7127..093ead7b 100644 --- a/examples/market/src/App.tsx +++ b/examples/market/src/App.tsx @@ -1,7 +1,7 @@ import { Environment, OrbitControls } from '@react-three/drei' import { Canvas } from '@react-three/fiber' import { EffectComposer, TiltShift2 } from '@react-three/postprocessing' -import { Root, Container, Image, Text, Fullscreen, DefaultProperties } from '@react-three/uikit' +import { Root, Container, Image, Text, Fullscreen, DefaultProperties, VideoContainer } from '@react-three/uikit' import { PlusCircle } from '@react-three/uikit-lucide' import { Defaults, colors } from '@/theme.js' import { DialogAnchor } from '@/dialog.js' @@ -108,6 +108,7 @@ export function MarketPage() { + {madeForYouAlbums.map((album) => ( ))} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4da8e442..8949863c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -36,3 +36,4 @@ export * from './content.js' export * from './fullscreen.js' export * from './suspending.js' export * from './portal.js' +export * from './video.js' diff --git a/packages/react/src/video.tsx b/packages/react/src/video.tsx new file mode 100644 index 00000000..9e422ae7 --- /dev/null +++ b/packages/react/src/video.tsx @@ -0,0 +1,91 @@ +import { + ComponentPropsWithoutRef, + ReactNode, + RefAttributes, + createContext, + forwardRef, + useContext, + useEffect, + useImperativeHandle, + useMemo, +} from 'react' +import { Image } from './image.js' +import { Texture, VideoTexture } from 'three' +import { signal } from '@preact/signals-core' + +const VideoContainerContext = createContext(undefined) + +export function useVideoElement(): HTMLVideoElement { + const element = useContext(VideoContainerContext) + if (element == null) { + throw new Error(`useVideoElement can only be executed inside a VideoContainer`) + } + return element +} + +export const VideoContainer: ( + props: Omit, 'src'> & { + src: string | MediaStream + volume?: number + preservesPitch?: boolean + playbackRate?: number + muted?: boolean + loop?: boolean + autoplay?: boolean + } & RefAttributes, +) => ReactNode = forwardRef( + ( + props: Omit, 'src'> & { + src: string | MediaStream + volume?: number + preservesPitch?: boolean + playbackRate?: number + muted?: boolean + loop?: boolean + autoplay?: boolean + }, + ref, + ) => { + const texture = useMemo(() => signal(undefined), []) + const aspectRatio = useMemo(() => signal(1), []) + const video = useMemo(() => document.createElement('video'), []) + useEffect(() => { + if (!props.autoplay) { + return + } + video.style.position = 'absolute' + video.style.width = '1px' + video.style.zIndex = '-1000' + video.style.top = '0px' + video.style.left = '0px' + document.body.append(video) + return () => video.remove() + }, [props.autoplay, video]) + video.playsInline = true + video.volume = props.volume ?? 1 + video.preservesPitch = props.preservesPitch ?? true + video.playbackRate = props.playbackRate ?? 1 + video.muted = props.muted ?? false + video.loop = props.loop ?? false + video.autoplay = props.autoplay ?? false + useEffect(() => { + if (typeof props.src === 'string') { + video.src = props.src + } else { + video.srcObject = props.src + } + const updateAspectRatio = () => (aspectRatio.value = video.videoWidth / video.videoHeight) + updateAspectRatio() + video.addEventListener('resize', updateAspectRatio) + return () => video.removeEventListener('resize', updateAspectRatio) + }, [aspectRatio, props.src, video]) + useEffect(() => { + const videoTexture = new VideoTexture(video) + texture.value = videoTexture + return () => videoTexture.dispose() + }, [texture, video]) + + useImperativeHandle(ref, () => video, [video]) + return + }, +) diff --git a/packages/uikit/src/vanilla/image.ts b/packages/uikit/src/vanilla/image.ts index 2a1742dd..b714337b 100644 --- a/packages/uikit/src/vanilla/image.ts +++ b/packages/uikit/src/vanilla/image.ts @@ -15,7 +15,11 @@ export class Image extends Parent { private readonly parentContextSignal = createParentContextSignal() private readonly unsubscribe: () => void - constructor(src: string | Signal, properties?: ImageProperties, defaultProperties?: AllOptionalProperties) { + constructor( + src: Signal | string | Texture | Signal | undefined, + properties?: ImageProperties, + defaultProperties?: AllOptionalProperties, + ) { super() setupParentContextSignal(this.parentContextSignal, this) this.matrixAutoUpdate = false diff --git a/packages/uikit/src/vanilla/index.ts b/packages/uikit/src/vanilla/index.ts index 7e2d132d..6e03e569 100644 --- a/packages/uikit/src/vanilla/index.ts +++ b/packages/uikit/src/vanilla/index.ts @@ -8,3 +8,4 @@ export * from './input.js' export * from './custom.js' export * from './content.js' export * from './fullscreen.js' +export * from './video.js' diff --git a/packages/uikit/src/vanilla/video.ts b/packages/uikit/src/vanilla/video.ts new file mode 100644 index 00000000..7d5238c3 --- /dev/null +++ b/packages/uikit/src/vanilla/video.ts @@ -0,0 +1,68 @@ +import { Image } from './image.js' +import { VideoTexture } from 'three' +import { ImageProperties } from '../index.js' +import { Signal, signal } from '@preact/signals-core' + +export class VideoContainer extends Image { + public readonly element: HTMLVideoElement + private readonly texture: VideoTexture + private readonly aspectRatio: Signal + private readonly updateAspectRatio: () => void + + constructor( + src: string | MediaStream, + volume?: number, + preservesPitch?: boolean, + playbackRate?: number, + muted?: boolean, + loop?: boolean, + autoplay?: boolean, + properties?: ImageProperties, + defaultProperties?: ImageProperties, + ) { + const element = document.createElement('video') + const texture = new VideoTexture(element) + const aspectRatio = signal(1) + super(texture, { aspectRatio, ...properties }, defaultProperties) + this.element = element + this.texture = texture + this.aspectRatio = aspectRatio + if (autoplay) { + this.element.style.position = 'absolute' + this.element.style.width = '1px' + this.element.style.zIndex = '-1000' + this.element.style.top = '0px' + this.element.style.left = '0px' + document.body.append(this.element) + } + this.element.playsInline = true + this.element.volume = volume ?? 1 + this.element.preservesPitch = preservesPitch ?? true + this.element.playbackRate = playbackRate ?? 1 + this.element.muted = muted ?? false + this.element.loop = loop ?? false + this.element.autoplay = autoplay ?? false + if (typeof src === 'string') { + this.element.src = src + } else { + this.element.srcObject = src + } + this.updateAspectRatio = () => (aspectRatio.value = this.element.videoWidth / this.element.videoHeight) + this.updateAspectRatio() + this.element.addEventListener('resize', this.updateAspectRatio) + } + + setProperties(properties?: ImageProperties): void { + super.setProperties({ + aspectRatio: this.aspectRatio, + ...properties, + }) + } + + destroy(): void { + super.destroy() + this.texture.dispose() + this.element.remove() + this.element.removeEventListener('resize', this.updateAspectRatio) + } +}