feat: improve media playback experience
This commit is contained in:
@@ -2,16 +2,25 @@ import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import mediaManager from '@/services/media-manager.service'
|
||||
import { Pause, Play } from 'lucide-react'
|
||||
import { Minimize2, Pause, Play, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string
|
||||
autoPlay?: boolean
|
||||
startTime?: number
|
||||
isMinimized?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AudioPlayer({ src, className }: AudioPlayerProps) {
|
||||
export default function AudioPlayer({
|
||||
src,
|
||||
autoPlay = false,
|
||||
startTime,
|
||||
isMinimized = false,
|
||||
className
|
||||
}: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
@@ -19,11 +28,21 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
|
||||
const [error, setError] = useState(false)
|
||||
const seekTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const isSeeking = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
|
||||
if (startTime) {
|
||||
setCurrentTime(startTime)
|
||||
audio.currentTime = startTime
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
togglePlay()
|
||||
}
|
||||
|
||||
const updateTime = () => {
|
||||
if (!isSeeking.current) {
|
||||
setCurrentTime(audio.currentTime)
|
||||
@@ -49,6 +68,28 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
const container = containerRef.current
|
||||
|
||||
if (!audio || !container) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) {
|
||||
audio.pause()
|
||||
}
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(container)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
@@ -86,8 +127,9 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'flex items-center gap-3 py-2 pl-2 pr-4 border rounded-full max-w-md',
|
||||
'flex items-center gap-3 py-2 px-2 border rounded-full max-w-md bg-background',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -114,6 +156,25 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
|
||||
<div className="text-sm font-mono text-muted-foreground">
|
||||
{formatTime(Math.max(duration - currentTime, 0))}
|
||||
</div>
|
||||
{isMinimized ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full shrink-0 text-muted-foreground"
|
||||
onClick={() => mediaManager.stopAudioBackground()}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full shrink-0 text-muted-foreground"
|
||||
onClick={() => mediaManager.playAudioBackground(src, audioRef.current?.currentTime || 0)}
|
||||
>
|
||||
<Minimize2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
46
src/components/BackgroundAudio/index.tsx
Normal file
46
src/components/BackgroundAudio/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import mediaManager from '@/services/media-manager.service'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AudioPlayer from '../AudioPlayer'
|
||||
|
||||
export default function BackgroundAudio({ className }: { className?: string }) {
|
||||
const [backgroundAudioSrc, setBackgroundAudioSrc] = useState<string | null>(null)
|
||||
const [backgroundAudio, setBackgroundAudio] = useState<React.ReactNode>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePlayAudioBackground = (event: Event) => {
|
||||
const { src, time } = (event as CustomEvent).detail
|
||||
if (backgroundAudioSrc === src) return
|
||||
|
||||
setBackgroundAudio(
|
||||
<FloatingAudioPlayer key={src + time} src={src} time={time} className={className} />
|
||||
)
|
||||
setBackgroundAudioSrc(src)
|
||||
}
|
||||
|
||||
const handleStopAudioBackground = () => {
|
||||
setBackgroundAudio(null)
|
||||
}
|
||||
|
||||
mediaManager.addEventListener('playAudioBackground', handlePlayAudioBackground)
|
||||
mediaManager.addEventListener('stopAudioBackground', handleStopAudioBackground)
|
||||
|
||||
return () => {
|
||||
mediaManager.removeEventListener('playAudioBackground', handlePlayAudioBackground)
|
||||
mediaManager.removeEventListener('stopAudioBackground', handleStopAudioBackground)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return backgroundAudio
|
||||
}
|
||||
|
||||
function FloatingAudioPlayer({
|
||||
src,
|
||||
time,
|
||||
className
|
||||
}: {
|
||||
src: string
|
||||
time?: number
|
||||
className?: string
|
||||
}) {
|
||||
return <AudioPlayer src={src} className={className} startTime={time} autoPlay isMinimized />
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import BackgroundAudio from '../BackgroundAudio'
|
||||
import AccountButton from './AccountButton'
|
||||
import ExploreButton from './ExploreButton'
|
||||
import HomeButton from './HomeButton'
|
||||
@@ -7,18 +8,18 @@ import NotificationsButton from './NotificationsButton'
|
||||
export default function BottomNavigationBar() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-0 w-full z-40 bg-background border-t flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0'
|
||||
)}
|
||||
className={cn('fixed bottom-0 w-full z-40 bg-background border-t')}
|
||||
style={{
|
||||
height: 'calc(3rem + env(safe-area-inset-bottom))',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||
}}
|
||||
>
|
||||
<HomeButton />
|
||||
<ExploreButton />
|
||||
<NotificationsButton />
|
||||
<AccountButton />
|
||||
<BackgroundAudio className="rounded-none border-x-0 border-t-0 border-b bg-background" />
|
||||
<div className="w-full flex justify-around items-center [&_svg]:size-4 [&_svg]:shrink-0">
|
||||
<HomeButton />
|
||||
<ExploreButton />
|
||||
<NotificationsButton />
|
||||
<AccountButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function MediaPlayer({
|
||||
const { autoLoadMedia } = useContentPolicy()
|
||||
const [display, setDisplay] = useState(autoLoadMedia)
|
||||
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoadMedia) {
|
||||
@@ -51,11 +52,12 @@ export default function MediaPlayer({
|
||||
video.crossOrigin = 'anonymous'
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
setError(false)
|
||||
setMediaType(video.videoWidth > 0 || video.videoHeight > 0 ? 'video' : 'audio')
|
||||
}
|
||||
|
||||
video.onerror = () => {
|
||||
setMediaType(null)
|
||||
setError(true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -63,6 +65,10 @@ export default function MediaPlayer({
|
||||
}
|
||||
}, [src, display, mustLoad])
|
||||
|
||||
if (error) {
|
||||
return <ExternalLink url={src} />
|
||||
}
|
||||
|
||||
if (!mustLoad && !display) {
|
||||
return (
|
||||
<div
|
||||
@@ -78,7 +84,7 @@ export default function MediaPlayer({
|
||||
}
|
||||
|
||||
if (!mediaType) {
|
||||
return <ExternalLink url={src} />
|
||||
return null
|
||||
}
|
||||
|
||||
if (mediaType === 'video') {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { SimpleUserAvatar } from '@/components/UserAvatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { hasBackgroundAudioAtom } from '@/services/media-manager.service'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
@@ -16,6 +18,7 @@ export default function NewNotesButton({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const hasBackgroundAudio = useAtomValue(hasBackgroundAudioAtom)
|
||||
const pubkeys = useMemo(() => {
|
||||
const arr: string[] = []
|
||||
for (const event of newEvents) {
|
||||
@@ -33,9 +36,13 @@ export default function NewNotesButton({
|
||||
<div
|
||||
className={cn(
|
||||
'w-full flex justify-center z-40 pointer-events-none',
|
||||
isSmallScreen ? 'fixed' : 'absolute bottom-6'
|
||||
isSmallScreen ? 'fixed' : 'absolute'
|
||||
)}
|
||||
style={isSmallScreen ? { bottom: 'calc(4rem + env(safe-area-inset-bottom))' } : undefined}
|
||||
style={{
|
||||
bottom: isSmallScreen
|
||||
? `calc(${hasBackgroundAudio ? 7.35 : 4}rem + env(safe-area-inset-bottom))`
|
||||
: '1rem'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { hasBackgroundAudioAtom } from '@/services/media-manager.service'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
|
||||
export default function ScrollToTopButton({
|
||||
@@ -13,6 +15,7 @@ export default function ScrollToTopButton({
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
|
||||
const hasBackgroundAudio = useAtomValue(hasBackgroundAudioAtom)
|
||||
const visible = !deepBrowsing && lastScrollTop > 800
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
@@ -31,8 +34,8 @@ export default function ScrollToTopButton({
|
||||
)}
|
||||
style={{
|
||||
bottom: isSmallScreen
|
||||
? 'calc(env(safe-area-inset-bottom) + 3.75rem)'
|
||||
: 'calc(env(safe-area-inset-bottom) + 0.75rem)'
|
||||
? `calc(env(safe-area-inset-bottom) + ${hasBackgroundAudio ? 7.25 : 3.85}rem)`
|
||||
: `calc(env(safe-area-inset-bottom) + 0.85rem)`
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import { cn, isInViewport } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
import mediaManager from '@/services/media-manager.service'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
|
||||
export default function VideoPlayer({ src, className }: { src: string; className?: string }) {
|
||||
const { autoplay } = useContentPolicy()
|
||||
const { muteMedia, updateMuteMedia } = useUserPreferences()
|
||||
const [error, setError] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoplay) return
|
||||
|
||||
const video = videoRef.current
|
||||
const container = containerRef.current
|
||||
|
||||
if (!video || !container) return
|
||||
if (!video || !container || error) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (entry.isIntersecting && autoplay) {
|
||||
setTimeout(() => {
|
||||
if (isInViewport(container)) {
|
||||
mediaManager.autoPlay(video)
|
||||
}
|
||||
}, 200)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!entry.isIntersecting) {
|
||||
mediaManager.pause(video)
|
||||
}
|
||||
},
|
||||
@@ -38,7 +40,34 @@ export default function VideoPlayer({ src, className }: { src: string; className
|
||||
return () => {
|
||||
observer.unobserve(container)
|
||||
}
|
||||
}, [autoplay])
|
||||
}, [autoplay, error])
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
const video = videoRef.current
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
updateMuteMedia(video.muted)
|
||||
}
|
||||
|
||||
video.addEventListener('volumechange', handleVolumeChange)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('volumechange', handleVolumeChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || video.muted === muteMedia) return
|
||||
|
||||
if (muteMedia) {
|
||||
video.muted = true
|
||||
} else {
|
||||
video.muted = false
|
||||
}
|
||||
}, [muteMedia])
|
||||
|
||||
if (error) {
|
||||
return <ExternalLink url={src} />
|
||||
@@ -56,7 +85,7 @@ export default function VideoPlayer({ src, className }: { src: string; className
|
||||
onPlay={(event) => {
|
||||
mediaManager.play(event.currentTarget)
|
||||
}}
|
||||
muted
|
||||
muted={muteMedia}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
import mediaManager from '@/services/media-manager.service'
|
||||
import { YouTubePlayer } from '@/types/youtube'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -17,12 +18,15 @@ export default function YoutubeEmbeddedPlayer({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { autoLoadMedia } = useContentPolicy()
|
||||
const { muteMedia, updateMuteMedia } = useUserPreferences()
|
||||
const [display, setDisplay] = useState(autoLoadMedia)
|
||||
const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url])
|
||||
const [initSuccess, setInitSuccess] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const playerRef = useRef<YouTubePlayer | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const muteStateRef = useRef(muteMedia)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoadMedia) {
|
||||
@@ -47,24 +51,48 @@ export default function YoutubeEmbeddedPlayer({
|
||||
initPlayer()
|
||||
}
|
||||
|
||||
let checkMutedInterval: NodeJS.Timeout | null = null
|
||||
function initPlayer() {
|
||||
try {
|
||||
if (!videoId || !containerRef.current || !window.YT.Player) return
|
||||
|
||||
let currentMuteState = muteStateRef.current
|
||||
playerRef.current = new window.YT.Player(containerRef.current, {
|
||||
videoId: videoId,
|
||||
playerVars: {
|
||||
mute: 1
|
||||
mute: currentMuteState ? 1 : 0
|
||||
},
|
||||
events: {
|
||||
onStateChange: (event: any) => {
|
||||
if (event.data === window.YT.PlayerState.PLAYING) {
|
||||
mediaManager.play(playerRef.current)
|
||||
} else if (event.data === window.YT.PlayerState.PAUSED) {
|
||||
} else if (
|
||||
event.data === window.YT.PlayerState.PAUSED ||
|
||||
event.data === window.YT.PlayerState.ENDED
|
||||
) {
|
||||
mediaManager.pause(playerRef.current)
|
||||
}
|
||||
},
|
||||
onReady: () => {
|
||||
setInitSuccess(true)
|
||||
checkMutedInterval = setInterval(() => {
|
||||
if (playerRef.current) {
|
||||
const mute = playerRef.current.isMuted()
|
||||
if (mute !== currentMuteState) {
|
||||
currentMuteState = mute
|
||||
|
||||
if (mute !== muteStateRef.current) {
|
||||
updateMuteMedia(currentMuteState)
|
||||
}
|
||||
} else if (muteStateRef.current !== mute) {
|
||||
if (muteStateRef.current) {
|
||||
playerRef.current.mute()
|
||||
} else {
|
||||
playerRef.current.unMute()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 200)
|
||||
},
|
||||
onError: () => setError(true)
|
||||
}
|
||||
@@ -80,9 +108,46 @@ export default function YoutubeEmbeddedPlayer({
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy()
|
||||
}
|
||||
if (checkMutedInterval) {
|
||||
clearInterval(checkMutedInterval)
|
||||
checkMutedInterval = null
|
||||
}
|
||||
}
|
||||
}, [videoId, display, mustLoad])
|
||||
|
||||
useEffect(() => {
|
||||
muteStateRef.current = muteMedia
|
||||
}, [muteMedia])
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current
|
||||
|
||||
if (!wrapper || !initSuccess) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const player = playerRef.current
|
||||
if (!player) return
|
||||
|
||||
if (
|
||||
!entry.isIntersecting &&
|
||||
[window.YT.PlayerState.PLAYING, window.YT.PlayerState.BUFFERING].includes(
|
||||
player.getPlayerState()
|
||||
)
|
||||
) {
|
||||
mediaManager.pause(player)
|
||||
}
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
|
||||
observer.observe(wrapper)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(wrapper)
|
||||
}
|
||||
}, [videoId, display, mustLoad, initSuccess])
|
||||
|
||||
if (error) {
|
||||
return <ExternalLink url={url} />
|
||||
}
|
||||
@@ -104,8 +169,10 @@ export default function YoutubeEmbeddedPlayer({
|
||||
if (!videoId && !initSuccess) {
|
||||
return <ExternalLink url={url} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn(
|
||||
'rounded-lg border overflow-hidden',
|
||||
isShort ? 'aspect-[9/16] max-h-[80vh] sm:max-h-[60vh]' : 'aspect-video max-h-[60vh]',
|
||||
|
||||
Reference in New Issue
Block a user