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)
+ }
+}