A mobile-first Next.js app for practicing English listening with interactive audio.
- 🎧 Polished audio player — big play button, ±10s skip, and playback speed (0.75×, 1×, 1.25×, 1.5×) for language learners
- 📱 Mobile-first UX — full-screen list on phones, sticky sidebar on desktop
- 🔍 Instant search — filter by exercise number or transcript text
- ⌨️ Keyboard-friendly —
Escreturns from detail to list on mobile, visible focus rings throughout - ♿ Accessible — proper ARIA labels, semantic HTML, tap targets ≥ 44×44px
- 🎨 Consistent design language — Inter font, indigo accent, subtle shadows
npx create-next-app@latest dim9-listening \
--typescript --tailwind --app \
--src-dir=false --import-alias="@/*" \
--no-eslint
cd dim9-listeningCopy the files from this bundle into the matching paths:
app/
layout.tsx # replaces the default
page.tsx # replaces the default
globals.css # replaces the default
components/
AudioPlayer.tsx # new
lib/
data.ts # new — where you put your exercises
tailwind.config.ts # replaces the default
postcss.config.js # replaces the default (unchanged from default, included for completeness)
npm run devOpen http://localhost:3000.
Edit lib/data.ts:
export const exercises: Exercise[] = [
{
id: 1,
text: `Transcript for exercise 1.
Paragraphs are separated by a blank line — they'll render as
separate <p> elements with proper spacing.`,
},
{ id: 2, text: 'Short transcript for exercise 2…' },
// …
];The audio URL is generated from the id:
export function getAudioUrl(id: number): string {
return `https://abiturient.az/storage/mp3/${id}_eng_TT_9-illik_2025.mp3`;
}If you host the MP3s elsewhere, change this function — that's the only place the URL pattern lives.
app/
├─ layout.tsx # root layout, loads Inter + JetBrains Mono via next/font
├─ page.tsx # main client component — list + detail in one view
└─ globals.css # Tailwind directives + minor base tweaks
components/
└─ AudioPlayer.tsx # self-contained audio player (play/pause, seek, skip, speed)
lib/
└─ data.ts # Exercise type, exercises array, getAudioUrl()
State management is plain useState — no context, no external library. The page keeps one piece of state (selectedId) and derives everything else via useMemo. The audio player is self-contained: pass it an src, it handles its own playback state.
Responsive behavior: below md (768 px), the list and the detail view are mutually exclusive — selecting an exercise swaps the view. From md up, both are visible side-by-side with a sticky sidebar.
Swap useState for useSearchParams + router.push to reflect selectedId in the URL (?ex=5). Keeps things a single page but makes exercises linkable.
If the source data ever includes word timings (e.g. JSON with [{ word, startMs, endMs }]), you can highlight words in the transcript as they're spoken by listening to the audio's timeupdate event and matching against the timings array.
Add a service worker (via next-pwa or a custom one) to cache the audio files after first play — makes the app usable without signal on the metro.
Tailwind's dark: variants work out of the box if you add darkMode: 'class' to tailwind.config.ts and a theme toggle. The indigo accent already plays well in dark mode; just swap bg-slate-50 → dark:bg-slate-900 and bg-white → dark:bg-slate-800 in the main surfaces.