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();
+ });
});