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

90
package-lock.json generated
View File

@@ -56,6 +56,7 @@
"franc-min": "^6.2.0",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
@@ -113,7 +114,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
@@ -126,7 +127,7 @@
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.25.9",
"js-tokens": "^4.0.0",
@@ -140,7 +141,7 @@
"version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz",
"integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=6.9.0"
}
@@ -149,7 +150,7 @@
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
"integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==",
"dev": true,
"devOptional": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.0",
@@ -179,7 +180,7 @@
"version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz",
"integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/parser": "^7.26.3",
"@babel/types": "^7.26.3",
@@ -207,7 +208,7 @@
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz",
"integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/compat-data": "^7.25.9",
"@babel/helper-validator-option": "^7.25.9",
@@ -223,7 +224,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"devOptional": true,
"dependencies": {
"yallist": "^3.0.2"
}
@@ -299,7 +300,7 @@
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz",
"integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
@@ -312,7 +313,7 @@
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz",
"integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/helper-module-imports": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9",
@@ -397,7 +398,7 @@
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=6.9.0"
}
@@ -406,7 +407,7 @@
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=6.9.0"
}
@@ -415,7 +416,7 @@
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz",
"integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=6.9.0"
}
@@ -438,7 +439,7 @@
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
"integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/template": "^7.25.9",
"@babel/types": "^7.26.0"
@@ -451,7 +452,7 @@
"version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz",
"integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/types": "^7.26.3"
},
@@ -1537,7 +1538,7 @@
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
"integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/code-frame": "^7.25.9",
"@babel/parser": "^7.25.9",
@@ -1551,7 +1552,7 @@
"version": "7.26.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz",
"integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.26.3",
@@ -1569,7 +1570,7 @@
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=4"
}
@@ -1578,7 +1579,7 @@
"version": "7.26.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz",
"integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==",
"dev": true,
"devOptional": true,
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
@@ -5998,7 +5999,7 @@
"version": "4.24.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz",
"integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -6124,7 +6125,7 @@
"version": "1.0.30001690",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
"integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -6727,7 +6728,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
"devOptional": true
},
"node_modules/core-js-compat": {
"version": "3.39.0",
@@ -7002,7 +7003,7 @@
"version": "1.5.75",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz",
"integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
"dev": true
"devOptional": true
},
"node_modules/embla-carousel": {
"version": "8.6.0",
@@ -7267,7 +7268,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=6"
}
@@ -7750,7 +7751,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=6.9.0"
}
@@ -8694,6 +8695,35 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jotai": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.15.0.tgz",
"integrity": "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@babel/core": ">=7.0.0",
"@babel/template": ">=7.0.0",
"@types/react": ">=17.0.0",
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"@babel/template": {
"optional": true
},
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -8715,7 +8745,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"devOptional": true,
"bin": {
"jsesc": "bin/jsesc"
},
@@ -8751,7 +8781,7 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"devOptional": true,
"bin": {
"json5": "lib/cli.js"
},
@@ -9870,7 +9900,7 @@
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true
"devOptional": true
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -11153,7 +11183,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"devOptional": true,
"bin": {
"semver": "bin/semver.js"
}
@@ -12213,7 +12243,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
"integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -13021,7 +13051,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
"devOptional": true
},
"node_modules/yaml": {
"version": "2.6.1",

View File

@@ -66,6 +66,7 @@
"franc-min": "^6.2.0",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",

View File

@@ -15,7 +15,9 @@ import {
useRef,
useState
} from 'react'
import BackgroundAudio from './components/BackgroundAudio'
import BottomNavigationBar from './components/BottomNavigationBar'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { normalizeUrl } from './lib/url'
import ExplorePage from './pages/primary/ExplorePage'
@@ -28,7 +30,6 @@ import { NotificationProvider } from './providers/NotificationProvider'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { routes } from './routes'
import modalManager from './services/modal-manager.service'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP
@@ -385,6 +386,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>

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

View File

@@ -5,6 +5,9 @@ import { createContext, useContext, useState } from 'react'
type TUserPreferencesContext = {
notificationListStyle: TNotificationStyle
updateNotificationListStyle: (style: TNotificationStyle) => void
muteMedia: boolean
updateMuteMedia: (mute: boolean) => void
}
const UserPreferencesContext = createContext<TUserPreferencesContext | undefined>(undefined)
@@ -21,6 +24,7 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
const [notificationListStyle, setNotificationListStyle] = useState(
storage.getNotificationListStyle()
)
const [muteMedia, setMuteMedia] = useState(true)
const updateNotificationListStyle = (style: TNotificationStyle) => {
setNotificationListStyle(style)
@@ -31,7 +35,9 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
<UserPreferencesContext.Provider
value={{
notificationListStyle,
updateNotificationListStyle
updateNotificationListStyle,
muteMedia,
updateMuteMedia: setMuteMedia
}}
>
{children}

View File

@@ -1,15 +1,23 @@
import { YouTubePlayer } from '@/types/youtube'
import { atom, getDefaultStore } from 'jotai'
export const hasBackgroundAudioAtom = atom(false)
const store = getDefaultStore()
type Media = HTMLMediaElement | YouTubePlayer
class MediaManagerService {
class MediaManagerService extends EventTarget {
static instance: MediaManagerService
private currentMedia: Media | null = null
constructor() {
super()
}
public static getInstance(): MediaManagerService {
if (!MediaManagerService.instance) {
MediaManagerService.instance = this
MediaManagerService.instance = new MediaManagerService()
}
return MediaManagerService.instance
}
@@ -24,7 +32,7 @@ class MediaManagerService {
if (this.currentMedia === media) {
this.currentMedia = null
}
pause(media)
_pause(media)
}
autoPlay(media: Media) {
@@ -34,6 +42,13 @@ class MediaManagerService {
) {
return
}
if (
store.get(hasBackgroundAudioAtom) &&
this.currentMedia &&
isMediaPlaying(this.currentMedia)
) {
return
}
this.play(media)
}
@@ -45,21 +60,31 @@ class MediaManagerService {
;(document.pictureInPictureElement as HTMLMediaElement).pause()
}
if (this.currentMedia && this.currentMedia !== media) {
pause(this.currentMedia)
_pause(this.currentMedia)
}
this.currentMedia = media
if (isMediaPlaying(media)) {
return
}
play(this.currentMedia).catch((error) => {
_play(this.currentMedia).catch((error) => {
console.error('Error playing media:', error)
this.currentMedia = null
})
}
playAudioBackground(src: string, time: number = 0) {
this.dispatchEvent(new CustomEvent('playAudioBackground', { detail: { src, time } }))
store.set(hasBackgroundAudioAtom, true)
}
stopAudioBackground() {
this.dispatchEvent(new Event('stopAudioBackground'))
store.set(hasBackgroundAudioAtom, false)
}
}
const instance = new MediaManagerService()
const instance = MediaManagerService.getInstance()
export default instance
function isYouTubePlayer(media: Media): media is YouTubePlayer {
@@ -83,14 +108,14 @@ function isPipElement(media: Media) {
return (media as any).webkitPresentationMode === 'picture-in-picture'
}
function pause(media: Media) {
function _pause(media: Media) {
if (isYouTubePlayer(media)) {
return media.pauseVideo()
}
return media.pause()
}
async function play(media: Media) {
async function _play(media: Media) {
if (isYouTubePlayer(media)) {
return media.playVideo()
}

View File

@@ -41,6 +41,9 @@ export interface YouTubePlayer {
getCurrentTime(): number
getDuration(): number
getPlayerState(): number
isMuted(): boolean
mute(): void
unMute(): void
}
export {}