Skip to content

Commit

Permalink
Switch video sources using JS
Browse files Browse the repository at this point in the history
  • Loading branch information
weotch committed Nov 30, 2023
1 parent 3a77181 commit 6cca758
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 20 deletions.
30 changes: 30 additions & 0 deletions packages/react/cypress/component/LazyVideo.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,33 @@ describe('playback', () => {
})

})

describe('responsive video', () => {

it('supports switching sources based on media', () => {
cy.mount(<LazyVideo
src={{
portrait: 'https://placehold.co/500x500.mp4?text=portrait',
landscape: 'https://placehold.co/500x250.mp4?text=landscape',
}}
sourceMedia={['(orientation:landscape)', '(orientation:portrait)']}
videoLoader={({ src, media}) => {
if (media?.includes('portrait')) return src.portrait
else return src.landscape
}}
alt='Responsive video test'
/>)

// Portrait loaded initially
cy.get('video').its('[0].currentSrc').should('contain', 'portrait')

// Switch to landscape
cy.viewport(500, 250)
cy.get('video').its('[0].currentSrc').should('contain', 'landscape')

// Switch back to portrait again
cy.viewport(500, 600)
cy.get('video').its('[0].currentSrc').should('contain', 'portrait')
})

})
20 changes: 20 additions & 0 deletions packages/react/cypress/component/ReactVisual.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ describe('sources', () => {
aspect: 1,
}
}}
video={{
landscape: {
url: 'https://placehold.co/500x250.mp4?text=landscape+video',
aspect: 2,
},
portrait: {
url: 'https://placehold.co/500x500.mp4?text=portrait+video',
aspect: 1,
}
}}
sourceTypes={['image/webp']}
sourceMedia={['(orientation: landscape)', '(orientation: portrait)']}
imageLoader={({ src, type, media, width }) => {
Expand All @@ -222,6 +232,10 @@ describe('sources', () => {
return `https://placehold.co/${dimensions}${ext}?text=`+
encodeURIComponent(text)
}}
videoLoader={({ src, media }) => {
return media?.includes('landscape') ?
src.landscape.url : src.portrait.url
}}
aspect={({ image, media }) => {
return media?.includes('landscape') ?
image.landscape.aspect :
Expand All @@ -239,6 +253,9 @@ describe('sources', () => {
.should('contain', 'https://placehold.co/640x320')
.should('contain', 'landscape')

// And a landscape video
cy.get('video').its('[0].currentSrc').should('contain', 'landscape')

// Check that the aspect is informing the size, not the image size
cy.get('[data-cy=react-visual]').hasDimensions(500, 250)

Expand All @@ -248,6 +265,9 @@ describe('sources', () => {
.should('contain', 'https://placehold.co/640x640')
.should('contain', 'portrait')

// And video
cy.get('video').its('[0].currentSrc').should('contain', 'portrait')

// Check aspect again
cy.get('[data-cy=react-visual]').hasDimensions(500, 500)

Expand Down
97 changes: 77 additions & 20 deletions packages/react/src/LazyVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@
"use client";

import { useInView } from 'react-intersection-observer'
import { useEffect, type ReactElement, useRef, useCallback } from 'react'
import { useMediaQueries } from '@react-hook/media-query'
import { useEffect, type ReactElement, useRef, useCallback, type MutableRefObject } from 'react'
import type { LazyVideoProps } from './types/lazyVideoTypes';
import type { SourceMedia } from './types/reactVisualTypes'
import { makeSourceVariants } from './lib/sources'
import { fillStyles, transparentGif } from './lib/styles'

type VideoSourceProps = {
src: Required<LazyVideoProps>['src']
videoLoader: LazyVideoProps['videoLoader']
media?: SourceMedia
}

type ResponsiveVideoSourceProps = Pick<Required<LazyVideoProps>,
'src' | 'videoLoader' | 'sourceMedia'
> & {
videoRef: VideoRef
}

type VideoRef = MutableRefObject<HTMLVideoElement | undefined>

// An video rendered within a Visual that supports lazy loading
export default function LazyVideo({
src, sourceMedia, videoLoader,
Expand Down Expand Up @@ -61,8 +67,10 @@ export default function LazyVideo({
// Simplify logic for whether to load sources
const shouldLoad = priority || inView

// Make source variants
const sourceVariants = makeSourceVariants({ sourceMedia })
// Multiple media queries and a loader func are necessary for responsive
const useResponsiveSource = sourceMedia
&& sourceMedia?.length > 1
&& !!videoLoader

// Render video tag
return (
Expand Down Expand Up @@ -92,23 +100,72 @@ export default function LazyVideo({
}}>

{/* Implement lazy loading by not adding the source until ready */}
{ shouldLoad && sourceVariants.map(({ media, key }) => (
<Source key={ key } {...{ videoLoader, src, media }} />
))}
{ shouldLoad && (useResponsiveSource ?
<ResponsiveSource { ...{ src, videoLoader, sourceMedia, videoRef }} /> :
<Source {...{ src, videoLoader }} />
)}
</video>
)
}

// Make a video source tag. Note, media attribute on source isn't supported
// in Chrome. This will need to be converted to a JS solution at some point.
// https://github.com/BKWLD/react-visual/issues/35
// Return a simple source element
function Source({
videoLoader, src, media
}: VideoSourceProps): ReactElement {
const srcUrl = videoLoader ?
videoLoader({ src, media }) :
src
return (
<source src={ srcUrl } {...{ media }} type='video/mp4' />
)
src, videoLoader
}: VideoSourceProps): ReactElement | undefined {
let srcUrl
if (videoLoader) srcUrl = videoLoader({ src })
else if (typeof src == 'string') srcUrl = src
if (!srcUrl) return
return (<source src={ srcUrl } type='video/mp4' />)
}

// Switch the video asset depending on media queries
function ResponsiveSource({
src, videoLoader, sourceMedia, videoRef
}: ResponsiveVideoSourceProps): ReactElement | undefined {

// Prepare a hash of source URLs and their media query constraint in the
// style expected by useMediaQueries
const queries = Object.fromEntries(sourceMedia.map(media => {
const url = videoLoader({ src, media })
return [url, media]
}))

// Find the src url that is currently active
const { matches } = useMediaQueries(queries)
const srcUrl = getFirstMatch(matches)

// Reload the video since the source changed
useEffect(() => reloadVideoWhenSafe(videoRef), [ matches ])

// Return new source
return (<source src={ srcUrl } type='video/mp4' />)
}

// Get the URL with a media query match
function getFirstMatch(matches: Record<string, boolean>): string | undefined {
for (const srcUrl in matches) {
if (matches[srcUrl]) {
return srcUrl
}
}
}

// Safely call load function on a video
function reloadVideoWhenSafe(videoRef: VideoRef): void {
if (!videoRef.current) return
const video = videoRef.current

// If already playing safely, load now
if (video.readyState >= 2) {
video.load()

// Else, wait for video to finish loading
} else {
const handleLoadedData = () => {
video.load()
video.removeEventListener('loadeddata', handleLoadedData)
}
video.addEventListener('loadeddata', handleLoadedData)
}
}

0 comments on commit 6cca758

Please sign in to comment.