feat: improve media playback experience

This commit is contained in:
codytseng
2025-10-11 23:19:07 +08:00
parent fb5434da91
commit 1f911c3a75
14 changed files with 353 additions and 66 deletions

View File

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

View 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 />
}

View File

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

View File

@@ -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') {

View File

@@ -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}

View File

@@ -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

View File

@@ -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>

View File

@@ -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]',