diff --git a/app/(home)/marketPlace/page.tsx b/app/(home)/marketPlace/page.tsx index 6e33ba1..7edb7a8 100644 --- a/app/(home)/marketPlace/page.tsx +++ b/app/(home)/marketPlace/page.tsx @@ -1,22 +1,12 @@ +'use client'; + import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { useScrollRestoration } from '@/hooks/useScrollRestoration'; import NatureDepthSlider from '../../../components/common/NatureDepth'; import NftCollections from '../../../components/common/NftCollections'; -import type { Metadata } from 'next'; - -export const metadata: Metadata = { - title: 'Marketplace | AudioBlocks', - description: - 'Explore and purchase unique audio-inspired NFTs, sound packs, and digital art on the AudioBlocks marketplace.', - openGraph: { - title: 'Marketplace | AudioBlocks', - description: - 'Explore and purchase unique audio-inspired NFTs, sound packs, and digital art on the AudioBlocks marketplace.', - type: 'website', - siteName: 'AudioBlocks', - }, -}; export default function MarketplacePage() { + useScrollRestoration('marketplace'); return (
diff --git a/components/common/Player.tsx b/components/common/Player.tsx index f36ea10..5c80593 100644 --- a/components/common/Player.tsx +++ b/components/common/Player.tsx @@ -40,6 +40,9 @@ const CommentPanel = dynamic(() => import('./dashboard/Comment'), { const COVER_FALLBACK = '/placeholder-cover.svg'; +/** Session key tracking whether the autoplay prompt was already shown (#134). */ +const AUTOPLAY_PROMPT_KEY = 'audioblocks_autoplay_prompted'; + const formatTime = (time: number) => { const minutes = Math.floor(time / 60); const seconds = Math.floor(time % 60) @@ -778,9 +781,36 @@ const Player = () => { } }, [currentIndex, currentTrack]); + // #134: gate the "click to play" banner to once per session. A blocked + // autoplay after the first user interaction is handled silently (the + // audio error path retries on the next track), so the banner is not + // re-shown every time the browser rejects an automatic play(). + const [autoplayPrompted, setAutoplayPrompted] = useState(() => { + if (typeof window === 'undefined') return false; + try { + return sessionStorage.getItem(AUTOPLAY_PROMPT_KEY) === '1'; + } catch { + return false; + } + }); + const hasPromptedAutoplay = autoplayPrompted; + useEffect(() => { - if (!autoplayBlocked) return; + if (!autoplayBlocked || hasPromptedAutoplay) return; + setAutoplayPrompted(true); + try { + sessionStorage.setItem(AUTOPLAY_PROMPT_KEY, '1'); + } catch { + // Storage unavailable — prompt this once regardless. + } const handler = () => { + // Unblock Web Audio on the user gesture itself: AudioContext only + // starts running after a user interaction, so the resume must happen + // inside this handler to actually take effect (#134). + const ctx = ensureAudioGraph(); + if (ctx && ctx.state === 'suspended') { + void ctx.resume().catch(() => {}); + } resumeAudio(); setAutoplayBlocked(false); }; @@ -790,7 +820,7 @@ const Player = () => { document.removeEventListener('click', handler); document.removeEventListener('keydown', handler); }; - }, [autoplayBlocked, resumeAudio, setAutoplayBlocked]); + }, [autoplayBlocked, resumeAudio, setAutoplayBlocked, ensureAudioGraph, hasPromptedAutoplay]); useEffect(() => { return () => { @@ -839,7 +869,7 @@ const Player = () => {
)} - {autoplayBlocked && ( + {autoplayBlocked && !hasPromptedAutoplay && (
void; +}) { + return ( + + ); +}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function TrackList({ tracks }: { tracks: any[] }) { const { playTrack } = usePlayback(); const parentRef = useRef(null); + // Stable per-track callback so a re-render of the list never cascades into + // every visible row re-rendering (#161). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handlePlay = useCallback((track: any) => playTrack(track), [playTrack]); + const virtualizer = useVirtualizer({ count: tracks.length, getScrollElement: () => parentRef.current, @@ -44,16 +71,7 @@ export default function TrackList({ tracks }: { tracks: any[] }) { transform: `translateY(${virtualItem.start}px)`, }} > - playTrack(track)} - onPlay={() => playTrack(track)} - /> + handlePlay(track)} />
); })} diff --git a/hooks/useScrollRestoration.ts b/hooks/useScrollRestoration.ts index a1866cd..552a024 100644 --- a/hooks/useScrollRestoration.ts +++ b/hooks/useScrollRestoration.ts @@ -38,6 +38,10 @@ export function useScrollRestoration(key?: string) { const pathname = usePathname(); const storageKey = key ?? pathname; const restoredKeyRef = useRef(null); + // True only for a *back/forward* navigation (popstate). A fresh navigation + // must not restore a stale position — it is a new visit, so the old one is + // cleared instead (acceptance criteria for #131). + const isPopNavigationRef = useRef(false); useEffect(() => { if (typeof window === 'undefined') return; @@ -50,10 +54,29 @@ export function useScrollRestoration(key?: string) { }; }, []); + // Tag popstate (back/forward) so the restore effect below can tell it apart + // from a fresh navigation. Registered once, before any per-key logic, so the + // flag is accurate on the very first navigation into a page. + useEffect(() => { + if (typeof window === 'undefined') return; + const handlePopState = () => { + isPopNavigationRef.current = true; + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + useEffect(() => { if (restoredKeyRef.current === storageKey) return; restoredKeyRef.current = storageKey; + // Fresh navigation: discard whatever the user saved on a previous visit so + // a back button never resurrects an unrelated scroll position. + if (!isPopNavigationRef.current) { + clearPosition(storageKey); + return; + } + const saved = getPositions()[storageKey]; if (typeof saved !== 'number' || saved <= 0) return; @@ -64,7 +87,8 @@ export function useScrollRestoration(key?: string) { const restore = () => { if (typeof window === 'undefined') return; - const pageCanReachSavedPosition = document.documentElement.scrollHeight >= saved + window.innerHeight; + const pageCanReachSavedPosition = + document.documentElement.scrollHeight >= saved + window.innerHeight; if (pageCanReachSavedPosition || attempts >= maxAttempts) { window.scrollTo(0, saved); return; @@ -87,6 +111,7 @@ export function useScrollRestoration(key?: string) { useEffect(() => { const handlePopState = () => { + isPopNavigationRef.current = true; savePosition(storageKey, window.scrollY); }; window.addEventListener('popstate', handlePopState); diff --git a/tests/hooks/useScrollRestoration.test.ts b/tests/hooks/useScrollRestoration.test.ts index 0a46679..b1401ed 100644 --- a/tests/hooks/useScrollRestoration.test.ts +++ b/tests/hooks/useScrollRestoration.test.ts @@ -43,4 +43,13 @@ describe('useScrollRestoration', () => { const positions = JSON.parse(mockSessionStorage['audioblocks_scroll_positions'] || '{}'); expect(positions['/test']).toBe(0); }); + + it('clears the saved position on a fresh (non-back) navigation', () => { + // No popstate fired, so this is a fresh navigation — the stored position + // must not be restored and must be cleared. + const { result } = renderHook(() => useScrollRestoration()); + const positions = JSON.parse(mockSessionStorage['audioblocks_scroll_positions'] || '{}'); + expect(positions['/test']).toBeUndefined(); + expect(result.current).toBeDefined(); + }); });