Skip to content

Commit

Permalink
feat: add theater mode and video player components
Browse files Browse the repository at this point in the history
  • Loading branch information
Drack112 committed Jan 11, 2025
1 parent a9b8186 commit d13c918
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 1 deletion.
3 changes: 2 additions & 1 deletion apps/web/src/components/pages/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Gradient } from '@/components/gradient'
import { SearchTrigger } from '@/components/search/trigger'

import { Seo } from '@/components/seo'
import { TheaterMode } from '@/components/theater-mode'
import { useGlobalSearchStore } from '@/store/use-global-search'
import { useLayoutState } from '@/store/use-layout-state'

Expand All @@ -13,7 +14,7 @@ export const HomePage = () => {

const renderContent = () => {
if (theaterMode) {
return <div>Mode</div>
return <TheaterMode />
}

return (
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/components/theater-mode/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { usePlayerState } from '@/store/use-player'
import { VideoPlayerPortalContainer } from '../video-player'
import { Lyrics } from '../player'

export const TheaterMode = () => {
const currentSong = usePlayerState((state) => state.currentSong)

if (!currentSong) {
return <p className='my-auto text-center'>No song playing</p>
}

return (
<div className='grid grid-cols-1 lg:grow lg:grid-cols-3'>
<VideoPlayerPortalContainer
className='aspect-video max-w-full lg:col-span-2 lg:h-full'
position='theater-mode'
/>
<div className='lg:col-span-1'>
<Lyrics
artist={currentSong?.artist}
song={currentSong?.title}
className='h-[calc(100svh/1.95)] lg:h-[calc(100svh-11rem)]'
/>
</div>
</div>
)
}
165 changes: 165 additions & 0 deletions apps/web/src/components/video-player/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { isEmpty, sample } from 'lodash'
import dynamic from 'next/dynamic'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import type ReactPlayer from 'react-player'

import { invidiousUrls } from '@/server/modules/invidious'
import { useLayoutState, type VideoPosition } from '@/store/use-layout-state'
import {
usePlayerInstance,
usePlayerProgressState,
usePlayerState,
} from '@/store/use-player'
import { ytGetId } from '@/utils/get-yt-url-id'

interface VideoPlayerPortalContainerProps {
position: VideoPosition
className?: string
}

export const VideoPlayerPortalContainer = (
props: VideoPlayerPortalContainerProps
) => {
const { position, className } = props

const setVideoPosition = useLayoutState((state) => state.setVideoPosition)
const theaterMode = useLayoutState((state) => state.theaterMode)

const dynamicData = useMemo(() => {
return { [`data-${theaterMode ? 'theater-mode' : position}`]: '' }
}, [position, theaterMode])

useEffect(() => {
setVideoPosition(theaterMode ? 'theater-mode' : position)
}, [setVideoPosition, theaterMode, position])

return <div className={className} {...dynamicData} />
}

const DynamicReactPlayer = dynamic(() => import('react-player/lazy'), {
ssr: false,
})

const VideoPlayer = memo(() => {
const { isPlaying, setIsPlaying, currentSong, setDuration, playNext } =
usePlayerState()

const { instance } = usePlayerInstance()

const playedProgress = usePlayerProgressState(
(state) => state.progress.played
)

const setPlayerProgress = usePlayerProgressState((state) => state.setProgress)

const videoPosition = useLayoutState((state) => state.videoPosition)

const onPlayerPlay = useCallback(() => {
if (!currentSong) {
return
}

setIsPlaying(true)
}, [currentSong, setIsPlaying])

const onPlayerEnd = useCallback(() => {
playNext({ isUserAction: false })
}, [playNext])

const onPlayerPause = useCallback(() => {
setIsPlaying(false)
}, [setIsPlaying])

const onPlayerProgress = useCallback(
(options: { playedSeconds: number; played: number }) => {
setPlayerProgress({
playedSeconds: options.playedSeconds,
played: options.played,
})
},
[setPlayerProgress]
)

const updatePlayerProgress = useCallback(
(node: Omit<ReactPlayer, 'refs'>) => {
if (playedProgress !== 0) {
node.seekTo(playedProgress, 'fraction')
}
},
[playedProgress]
)

const [videoChoice, setVideoChoice] = useState(0)

const url = useMemo(() => {
if (isEmpty(currentSong?.urls)) return undefined

const isSoundCloud =
currentSong?.urls?.length === 1 &&
currentSong?.urls[0].includes('soundcloud.com')

if (isSoundCloud) {
return currentSong?.urls![0]
}

const videoUrl = currentSong?.urls?.[videoChoice]

const isVideoUrlBlocked = !videoUrl

if (isVideoUrlBlocked) {
return `${sample(invidiousUrls)}/latest_version?id=${ytGetId(currentSong?.urls?.[0] ?? '')?.id}&itag=18`
}

return videoUrl
}, [currentSong?.urls, videoChoice])

useEffect(() => {
setVideoChoice(0)
}, [currentSong?.title, currentSong?.artist])

return (
<DynamicReactPlayer
width='100%'
height='100%'
style={{ minHeight: 48 }}
playing={isPlaying}
url={url}
controls
onPlay={onPlayerPlay}
onPause={onPlayerPause}
onEnded={onPlayerEnd}
stopOnUnmount={false}
progressInterval={500}
onProgress={onPlayerProgress}
position={videoPosition}
onError={(error) => {
console.log(error)
if (error) {
setVideoChoice((prev) => prev + 1)
}
}}
onDuration={(duration) => {
if (!currentSong) {
return
}
setDuration(duration)
}}
onReady={(node: Omit<ReactPlayer, 'refs'>) => {
// @ts-expect-error - hide from redux devtools for performance
node.toJSON = () => ({ hidden: 'to help redux devtools :)' })

const previousPosition = instance.current?.props.position

instance.current = node

if (previousPosition !== node.props.position) {
updatePlayerProgress(instance.current)
}
}}
/>
)
})

VideoPlayer.displayName = 'VideoPlayer'

export { VideoPlayer }
20 changes: 20 additions & 0 deletions apps/web/src/layout/main/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ import { ModalProvider } from '@/providers/modal-provider'
import { useLayoutState } from '@/store/use-layout-state'
import { AddToPlaylistDndContext } from '@/hooks/add-to-playlist'
import { FooterPlayer } from '@/components/player'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { VideoPlayer } from '@/components/video-player'

const VideoPlayerPortal = () => {
const videoPosition = useLayoutState((state) => state.videoPosition)

const [domReady, setDomReady] = useState(false)

useEffect(() => {
setDomReady(true)
}, [])

const container = domReady
? document.querySelector(`[data-${videoPosition}]`)
: null

return domReady && container ? createPortal(<VideoPlayer />, container) : null
}

const Attribution = () => {
const theaterMode = useLayoutState((state) => state.theaterMode)
Expand Down Expand Up @@ -47,6 +66,7 @@ const MainLayout = ({ children }: { children: React.ReactNode }) => {
<FooterPlayer />
<Toaster />
<ModalProvider />
<VideoPlayerPortal />
</>
)
}
Expand Down

0 comments on commit d13c918

Please sign in to comment.