diff --git a/package-lock.json b/package-lock.json index 6e35c40..7a5a448 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3edef29..ee6e1ea 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index b9de6fc..c8e567c 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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 }) { + diff --git a/src/components/AudioPlayer/index.tsx b/src/components/AudioPlayer/index.tsx index f1ad967..bce7082 100644 --- a/src/components/AudioPlayer/index.tsx +++ b/src/components/AudioPlayer/index.tsx @@ -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(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() const isSeeking = useRef(false) + const containerRef = useRef(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 (
e.stopPropagation()} @@ -114,6 +156,25 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
{formatTime(Math.max(duration - currentTime, 0))}
+ {isMinimized ? ( + + ) : ( + + )}
) } diff --git a/src/components/BackgroundAudio/index.tsx b/src/components/BackgroundAudio/index.tsx new file mode 100644 index 0000000..d338292 --- /dev/null +++ b/src/components/BackgroundAudio/index.tsx @@ -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(null) + const [backgroundAudio, setBackgroundAudio] = useState(null) + + useEffect(() => { + const handlePlayAudioBackground = (event: Event) => { + const { src, time } = (event as CustomEvent).detail + if (backgroundAudioSrc === src) return + + setBackgroundAudio( + + ) + 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 +} diff --git a/src/components/BottomNavigationBar/index.tsx b/src/components/BottomNavigationBar/index.tsx index 574721b..32c9136 100644 --- a/src/components/BottomNavigationBar/index.tsx +++ b/src/components/BottomNavigationBar/index.tsx @@ -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 (
- - - - + +
+ + + + +
) } diff --git a/src/components/MediaPlayer/index.tsx b/src/components/MediaPlayer/index.tsx index dcf1f63..b686da6 100644 --- a/src/components/MediaPlayer/index.tsx +++ b/src/components/MediaPlayer/index.tsx @@ -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 + } + if (!mustLoad && !display) { return (
+ return null } if (mediaType === 'video') { diff --git a/src/components/NewNotesButton/index.tsx b/src/components/NewNotesButton/index.tsx index ea5cd84..e6e11bb 100644 --- a/src/components/NewNotesButton/index.tsx +++ b/src/components/NewNotesButton/index.tsx @@ -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({
diff --git a/src/components/YoutubeEmbeddedPlayer/index.tsx b/src/components/YoutubeEmbeddedPlayer/index.tsx index fa4540b..56fdead 100644 --- a/src/components/YoutubeEmbeddedPlayer/index.tsx +++ b/src/components/YoutubeEmbeddedPlayer/index.tsx @@ -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(null) const containerRef = useRef(null) + const wrapperRef = useRef(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 } @@ -104,8 +169,10 @@ export default function YoutubeEmbeddedPlayer({ if (!videoId && !initSuccess) { return } + return (
void + + muteMedia: boolean + updateMuteMedia: (mute: boolean) => void } const UserPreferencesContext = createContext(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 {children} diff --git a/src/services/media-manager.service.ts b/src/services/media-manager.service.ts index 7e0b5ec..714574e 100644 --- a/src/services/media-manager.service.ts +++ b/src/services/media-manager.service.ts @@ -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() } diff --git a/src/types/youtube.d.ts b/src/types/youtube.d.ts index 520d5d0..50e5357 100644 --- a/src/types/youtube.d.ts +++ b/src/types/youtube.d.ts @@ -41,6 +41,9 @@ export interface YouTubePlayer { getCurrentTime(): number getDuration(): number getPlayerState(): number + isMuted(): boolean + mute(): void + unMute(): void } export {}