Freeframe

Documentation

Echo

v1.0.0Updated Nov 12, 2026A bold, dark podcast website with a real audio player, chapters, a searchable transcript and an episode archive.

Overview#

Echo is a website template for a podcast, built for Overtone, a fictional independent interview show about how things get made. Its hosts, Nadia Ferreira (an industrial designer) and Eli Brandt (a science journalist), record in a former piano workshop in Philadelphia and have published 62 episodes over four seasons. The site has a home page with the latest episode, an episode archive, episode pages with chapters, show notes and a transcript, hosts, subscribe, advertise and be-a-guest pages, and an audio player that keeps playing while the visitor moves around the site. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site. The design is dark-first: a blackcurrant night background, wide Syne headlines, mono labels and colorful SVG cover art. This guide covers version 1.0.0. See it live at freeframe.dev/templates/echo.

Quick start#

You need Node.js 24 or newer and pnpm.

sh
pnpm install
pnpm dev

Open http://localhost:3000. Edits to pages, components and data reload instantly. pnpm install also runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint use.

Script What it does
pnpm dev Development server
pnpm generate Static site in .output/public/
pnpm build Node.js server build in .output/ (pages are still prerendered)
pnpm preview Serve the last build locally
pnpm lint ESLint
pnpm typecheck nuxt prepare and vue-tsc

Pages#

Route File Purpose and notable features
/ app/pages/index.vue Hero with the latest episode and its player, "listen on" links, guest ticker, four featured episodes, topics with counts, hosts, listener reviews, newsletter form
/episodes app/pages/episodes.vue Archive: search, season and topic filters, sort, filter chips, load more, empty state (/episodes?topic=sound&season=4&q=room&sort=oldest)
/episode app/pages/episode.vue One episode: cover, full player with chapter markers, about and pull quote, chapters (#chapters), show notes (#notes), transcript (#transcript), guest card, share box (#share), related episodes. /episode?id=18&t=754 opens episode 18 at 12:34; without id it shows the latest episode
/hosts app/pages/hosts.vue Host cards, numbers, timeline, how an episode is made, press quotes (#press), team
/subscribe app/pages/subscribe.vue App links (#apple-podcasts, #spotify, #youtube, #pocket-casts, #overcast, #rss), copyable RSS feed, next release date, FAQ, email sign-up
/sponsor app/pages/sponsor.vue Audience stats, growth line chart, occupations donut, age and country bars, ad formats, rate card (#rates), inquiry form (#enquire; /sponsor?format=season checks a format)
/guest app/pages/guest.vue What we look for, what happens next, pitch form
404 app/error.vue "Dead air" page with links back into the site, generated as 404.html

Project structure#

echo/
  nuxt.config.ts            base path, head defaults, SITE_URL, prerendered routes, Tailwind
  app/
    app.vue                 the layout and the current page (keyed so a link to the open page restarts it)
    layouts/default.vue     skip link, header, the page, footer, mini player, toast, back to top
    pages/                  one file per page (index.vue, episodes.vue, episode.vue, hosts.vue, ...)
    error.vue               the 404 page
    components/
      HeroPlayer.vue        the home page player (always the newest episode)
      MiniPlayer.vue        the mini player docked to the bottom of every page
      Player*.vue           player controls: PlayerToggle, PlayerWave, PlayerRate, PlayerVolume, ...
      EpisodeView.vue       the episode page: player, chapters, notes, transcript, guest, share
      EpisodeMissing.vue    "Episode not found"
      EpCard.vue, EpRow.vue episode cards and archive rows; PlayButton.vue is their play button
      CoverArt.vue, WaveSvg.vue, GuestAvatar.vue   SVG artwork drawn from the episode data
      GrowthChart.vue, WorkDonut.vue, BarList.vue, ChartTable.vue   advertise page charts
      NewsletterForm.vue, SubscribeForm.vue, SponsorForm.vue, GuestForm.vue   the four forms
      SiteHeader.vue, SiteFooter.vue, ListenOn.vue, SiteToast.vue, BackToTop.vue, icons
    composables/
      usePlayerUI.ts        one player's state and keyboard handling (usePlayerRoot), shared by its controls
      useEpisodeProgress.ts saved progress as drawn on cards and rows
      useValidatedForm.ts   form validation and success states
      usePageSeo.ts, useCurrentPage.ts, useAsset.ts
    data/
      episodes.ts           every episode (EPISODES)
      details.ts            chapters, show notes, transcripts and guest bios (DETAILS)
      show.ts               show name, feed, credits, topics and seasons (SHOW, TOPICS, SEASONS)
      site.ts               menu, podcast app links, contact addresses (NAV, APPS, CONTACT)
      audience.ts           advertise page chart numbers (AUDIENCE)
    utils/
      player.ts             the player engine and saved progress
      episodes.ts           look-ups, generated chapters/notes/transcripts, archive filters
      format.ts, art.ts, validate.ts, toast.ts, safeStorage.ts
    plugins/                scroll reveals, same-page links, the player's base path, stable builds
    assets/css/main.css     Tailwind CSS: fonts, design tokens (@theme) and component classes
    assets/fonts/           woff2 font files and their license (OFL.txt)
  public/img/               favicon, host portraits, social image (og-image.png) and its SVG source
  template.json, README.md, DOCS.md, CHANGELOG.md

Customizing#

Brand and colors#

The design tokens live in the @theme block of app/assets/css/main.css. Every utility such as bg-night, text-muted or text-lilac comes from them.

Token Default Used for
--color-night #0e0c13 Page background
--color-ink-900, --color-ink-800, --color-ink-700 #15121c, #1c1826, #272132 Sections, cards and inputs, hover states
--color-line #342c42 Borders, rules, the unplayed part of waveforms
--color-paper #f4efe6 Headings and body text
--color-muted, --color-subtle #b6adc2, #948aa3 Secondary text and captions
--color-lilac #b9a4ff Main accent: buttons, links, the played part of waveforms, focus rings
--color-tomato, --color-sun, --color-sky, --color-mint "On air" dots, stars and chapter markers, topic colors

To change the main accent, edit the lilac tokens:

css
@theme {
  --color-lilac: #7ee0d0;
  --color-lilac-soft: #b4f0e6;
  --color-lilac-deep: #3fbfa8;
}

A few colors are also written in data and SVG because they are drawn into artwork: the season cover colors in SEASONS (app/data/show.ts), the occupation colors in AUDIENCE.work, the chart strokes in app/components/GrowthChart.vue and WorkDonut.vue, the logo in app/components/LogoMark.vue and the colors inside the SVG files in public/img/. Echo is a dark-only design (color-scheme: dark in main.css and in the color-scheme and theme-color tags in nuxt.config.ts). There is no light theme or toggle.

Typography#

The fonts are self-hosted from app/assets/fonts/ and declared with @font-face at the top of app/assets/css/main.css. They are the same woff2 files Google Fonts serves to the HTML version (SIL Open Font License), so text measures exactly the same:

  • Syne (600–800) for display headings, through the display utility and the .h-hero, .h-page and .h-section classes, and inside the SVG cover art.
  • Schibsted Grotesk (400–800, italic 400–600) for body text (--font-sans).
  • DM Mono (400 and 500) for labels, timestamps and episode numbers (--font-mono, the .meta class).

Each family has a latin and a latin-ext file split by unicode-range, so names such as Mira Kovač render in the same font. To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the matching stack in @theme (--font-display, --font-sans, --font-mono).

Content#

Static text (hero, hosts, reviews, press, rates, FAQ) is written in each page's template. Everything about episodes comes from app/data/:

Export File What it holds
SHOW show.ts Show name, the RSS feed address shown on /subscribe, and the credits line added to show notes
TOPICS show.ts Topic ids, labels, color dot class and a one-line description (filters, home topic list)
SEASONS show.ts Season labels, years and the cover colors for each season
EPISODES episodes.ts One Episode object per episode
DETAILS details.ts Optional chapters, show notes, transcript and guest bio and links per episode id
NAV, APPS, CONTACT site.ts Main menu, podcast apps (name, your show's address in the app, blurb), email addresses
AUDIENCE audience.ts Numbers for the advertise page charts

An episode looks like this. duration is in seconds and date is YYYY-MM-DD:

ts
{ id: 63, season: 4, title: "The Sound of Snow", guest: "Dr. Mira Holm", role: "Snow physicist", topic: "science", date: "2026-10-21", duration: 3540,
  summary: "Why fresh snow makes a city quiet, and how Mira measures it.",
  quote: "Snow is a very large, very cold acoustic panel.",
  src: "audio/episode-63.mp3" },

The newest date becomes the latest episode everywhere (latest() in app/utils/episodes.ts): the home hero, the "New episode" links, /episode without an id and the subscribe page's next release date (every other Wednesday after it). The home page's "Start with these four" list is START_HERE in app/pages/index.vue.

To give an episode real chapters, notes and a transcript, add an entry to DETAILS with its id. Times are seconds from the start. Notes are blocks: a string is a paragraph, { h } a heading, { ul } a list and { links } a list of [label, href] pairs (internal links like /episode?id=62 use <NuxtLink>; https: links open in a new tab):

ts
63: {
  bio: "Mira Holm studies how snow absorbs sound at a research station in Fairbanks, Alaska.",
  links: [["Mira’s lab", "https://example.com/snow-lab"]],
  chapters: [{ t: 0, title: "Cold open" }, { t: 140, title: "Meet Mira" }, { t: 1260, title: "Measuring quiet" }],
  notes: ["Paragraph of show notes.", { h: "Mentioned" }, { links: [["Episode 62: The Quietest Room on Earth", "/episode?id=62"]] }],
  transcript: [[0, "Eli", "From a former piano workshop in Philadelphia, this is Overtone."], [140, "Mira", "Thanks for having me."]],
},

Episodes without a DETAILS entry still get a full page: chaptersFor() spreads a standard running order across the episode, notesFor() builds notes from the summary, chapters and related episodes, and transcriptFor() writes a short excerpt marked as such. Replace these with your real content when you can.

Images and artwork#

There are no photos. All artwork is SVG:

  • Cover art is drawn by <CoverArt :ep="ep" /> (app/components/CoverArt.vue): the season color from SEASONS, a motif for the topic (waveform for sound, orbits for science, a shape grid for design, a truss for engineering, woven scales for craft) and the episode number. Covers are square. To use your own artwork, render an <img :src="asset(...)" alt=""> in CoverArt.vue instead, with square images of at least 600×600 in public/ (useAsset() adds the base path).
  • Waveforms come from bars() in app/utils/art.ts and <WaveSvg>, seeded by the episode id, so each episode always has the same shape. They are decorative; they don't analyze audio.
  • Guest avatars (<GuestAvatar>) are the guest's initials on the season color.
  • Host portraits are public/img/host-nadia.svg and public/img/host-eli.svg (5:6). Replace them with photos of the same ratio, for example 800×960, and keep the alt text.
  • Social image: public/img/og-image.png (1200×630), rendered from public/img/og-image.svg. Edit the SVG, export it to PNG at 1200×630 and replace the PNG. The favicon is public/img/favicon.svg.

The header (app/components/SiteHeader.vue) and footer (app/components/SiteFooter.vue) are rendered once by the layout. The main menu is NAV in app/data/site.ts; the mobile menu adds Home and Subscribe. The current page gets aria-current="page" from useCurrentPage() (the episode page counts as Episodes). The "New episode" links point at latest(). The "Listen on" links (app/components/ListenOn.vue) and the footer's app links go to the matching card on /subscribe; the cards on app/pages/subscribe.vue link to your show in each app. Replace the https://example.com/overtone/... addresses there and in APPS.

To add a page, create app/pages/<name>.vue (copy hosts.vue for the <main> element and usePageSeo()), add its path to routes in nuxt.config.ts so it is generated, and add it to NAV or the footer.

Features#

The player#

The engine is app/utils/player.ts. It lives outside the component tree as a module-level singleton, so there is only ever one player (and at most one <audio> element) for the whole site, and it keeps running while Nuxt swaps pages. Components render from playerSnapshot (a shallowRef replaced on every change) and control it with the Player object: load, play, pause, toggle, seek, skip, cycleRate, setVolume, toggleMute, unload, plus restore() and cue() (below).

Each player calls usePlayerRoot() from app/composables/usePlayerUI.ts with the episode it controls and puts its onKeydown on its root element:

Player File Controls
Home hero (data-player-ui="latest") HeroPlayer.vue Always the newest episode
Episode page (#episode-player) EpisodeView.vue The episode in the URL
Mini player (#mini-player) MiniPlayer.vue Whatever is loaded

Inside a player, use any of <PlayerToggle>, <PlayerBack>, <PlayerFwd>, <PlayerRate>, <PlayerMute>, <PlayerVolume>, <PlayerClose>, <PlayerWave> (the waveform scrubber: an invisible <input type="range"> over two SVG layers; size="sm" or "lg", chapter-marks), <PlayerCur>, <PlayerDur> and <PlayerProgressBar>; they find their player with usePlayerContext(). Pressing play anywhere pauses everything else, and every player and play button (<PlayButton>) stays in sync. Settings: skip length is SKIP (15 seconds) and the speed cycle is RATES (1, 1.25, 1.5, 1.75, 2, 0.75), both in player.ts.

Real audio or a simulated clock. If an episode has a src, Player.load() creates an <audio> element for it, and the time, duration, speed, volume and end of the episode come from the real file. Episodes without src play on a clock: a timer advances the position every 250 ms by the elapsed time multiplied by the speed, until it reaches duration. That is how the demo plays all 62 episodes with no audio files. To go live, add src to each episode: a file in public/ written without a leading slash (for example audio/episode-63.mp3; the site's base path is added for you) or your podcast host's full MP3 URL. Keep duration accurate: it is shown before the file has loaded and used for chapters and the waveform.

Keyboard. Inside any player: Space or K play or pause, ←/→ or J/L skip 15 seconds, ↑/↓ change the volume and M mutes. On the waveform slider itself the arrows move 5 seconds, Page Up/Page Down a minute and Home/End jump to the ends. On the episode page, K, J, L and M also work anywhere on the page while you are not typing in a field. The episode player lists the shortcuts under "Shortcuts".

Accessibility. Every player is labeled (the episode and mini players are regions); the play button's label says what will happen ("Play episode 62: The Quietest Room on Earth"); the seek slider announces its position in words ("12 minutes 34 seconds of 57 minutes 56 seconds"); mute is a toggle button with aria-pressed. Animated equalizer bars and the ripple rings around the home cover only move while playing and stay still with reduced motion.

Mini player and saved progress#

<MiniPlayer> is rendered by app/layouts/default.vue, so it stays mounted while pages change: playback carries on without a gap. It slides up as soon as an episode is loaded, and the page gets extra bottom padding (the has-mini class on <html>) so nothing is hidden behind it. On an episode page it tucks away while that episode's own player is on screen (the page reports this with Player.setPage() from an IntersectionObserver). The close button stops playback and hides it; the position is kept.

The engine saves to localStorage every five seconds while playing, and on pause, seek and when the page is closed (see Saved data). After a full page load or on a later visit, Player.restore() (called once by the mini player when it mounts) loads the same episode at the same second; if it was playing it carries on (a real audio file may be blocked by the browser's autoplay rules, in which case it waits, paused, for the visitor to press play). Episode cards and archive rows show "23 min left" or "Played" with a progress bar: the text is worked out when the list is drawn (on page load, or when the archive's filters change; useDrawnProgress() in app/composables/useEpisodeProgress.ts) and the bar follows playback. Nothing is read from storage until the page has loaded, so the generated HTML hydrates without mismatches.

Episode archive#

app/pages/episodes.vue filters EPISODES with filterEpisodes() from app/utils/episodes.ts: a search across title, guest, role, summary and topic (matches are highlighted by <MarkText>), one season, one topic and a sort (newest, oldest, longest, shortest in SORTS). The state is read from the URL by readFilters() and written back with history.replaceState, so filtered views can be shared and bookmarked; unknown values are ignored. Topic buttons show live counts within the current season and search. Results show 12 at a time (PAGE_SIZE); "Show more" focuses the first new episode. Active filters appear as removable chips, and an empty state offers to clear them. Filter buttons use aria-pressed, and the result count is a polite live region. The generated page shows the unfiltered archive; the filters from the URL apply once the page has loaded (or straight away after a link click, such as a topic on the home page).

Chapters, transcript and sharing#

app/pages/episode.vue reads id and t from the URL (an unknown id shows <EpisodeMissing>) and renders <EpisodeView> for the episode. The generated HTML is the newest episode; the browser updates the title and description for the episode in the URL.

  • Chapters: each is a button that seeks there and plays. The current chapter gets aria-current="true", is named above the waveform, and appears as a dot under the waveform (<PlayerWave chapter-marks>).
  • Transcript: each line is a button that seeks and plays. The current line is highlighted while you listen, and with "Follow along while playing" checked the transcript box scrolls to keep it in view (it stops following for a few seconds after you scroll it yourself, and scrolls instantly with reduced motion). The search box filters lines and highlights matches.
  • Share: the share box shows the episode link; check "Start at" to add &t= with the current second, then "Copy link". Opening a link with ?t= cues the episode at that point, paused, with a note saying so (Player.cue(); if another episode is already playing, it keeps playing and the shared position is saved for this one). t also accepts 12:34 or 1:02:03. Where the browser supports it, a "More" button opens the system share sheet.

Charts#

GrowthChart.vue and WorkDonut.vue draw the growth line chart and the occupation donut as inline SVG, and BarList.vue the age and country charts as CSS bars, from AUDIENCE. They are part of the generated HTML. Each SVG chart has a text alternative in aria-label, the bar lists are readable as text, and "Show the numbers" opens a data table (ChartTable.vue) for the growth chart. Change the numbers in AUDIENCE and the aria-label text together.

Header, menu, reveals and back to top#

The header turns solid after scrolling. Below 1024 px the menu button opens a full-height menu (aria-expanded, Esc closes it and returns focus to the button; following a link or widening the window closes it). Elements with .reveal fade in on scroll (app/plugins/reveal.client.ts, which also picks up elements added later), and revealing one also reveals everything above it; with reduced motion they are visible straight away. The back-to-top button (app/components/BackToTop.vue) lifts itself above the mini player and toasts (anything marked data-back-to-top-avoid). To remove it, delete <BackToTop /> from the layout and the .back-to-top styles in main.css.

In the HTML version every link loads a page. Here links are client-side navigations, so app/plugins/same-page.client.ts and the page key in app/app.vue make a link to the page already showing (Episodes on a filtered archive, the newest episode on its own page) start that page afresh at the top, and another episode on the episode page (?id=) opens from the top. experimental.navigationRepaint is off in nuxt.config.ts, so a link opens the next page straight away instead of after Nuxt's 100 ms repaint pause.

Forms#

All four forms are components (NewsletterForm.vue, SubscribeForm.vue, SponsorForm.vue, GuestForm.vue) that use useValidatedForm() (app/composables/useValidatedForm.ts) with the rules in app/utils/validate.ts. Fields stay uncontrolled (their values are read from the form), errors appear on blur and on submit, re-check as you type once shown, and the first invalid field is focused. Each error is in the element <field id>-error, linked with aria-describedby. On success the form is replaced by its success box, which takes focus.

Attribute Rule
required Must not be empty (checkboxes must be checked; message from data-msg)
type="email" Must look like an email address
type="url" Must look like a web address (example.com/work)
minlength Minimum length, with how many characters to go
data-label Words used in the "Enter …" / "Choose …" message
<fieldset data-required-group data-msg="..."> At least one checkbox checked
Form Page Checks
<NewsletterForm> (#newsletter-form) Home Email
<SubscribeForm> (#sub-form) Subscribe Email
<SponsorForm> (#sponsor-form) Advertise Name, company, email, optional website, at least one format, budget, message (30+ characters)
<GuestForm> (#guest-form) Be a guest Name, email, their name when "Someone else" is chosen, field, topic, optional link, story (120+ characters with a live counter), consent

The forms don't send anything until you connect them, although their success messages read as if they had. To collect submissions, post the form in onValid() in useValidatedForm.ts before showing the success state, for example to Formspree:

ts
async function onValid(el: HTMLFormElement) {
  const res = await fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(el),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return toast("Something went wrong. Please email us.");
  // ...then the existing lines: sent.value = ..., el.reset()
}

(toast is in app/utils/toast.ts.) Use a different endpoint per form by checking el.id. Every field has a name, so FormData carries all values; keep a name on any field you add. For the newsletter, most email services (Buttondown, Mailchimp, ConvertKit) accept the same fetch to their form endpoint. Then check the success messages in the form components match what really happens: the newsletter ones promise a confirmation email, the sponsor one a reply from Rosa within two business days and the guest one a reply within three weeks. The sponsor and guest messages show a short reference (OT- and four characters) made by makeRef() in useValidatedForm.ts; show your form service's own reference instead if it returns one.

If you deploy with pnpm build to a host that runs Node.js, you can post to your own server route instead, for example server/api/pitch.post.ts:

ts
export default defineEventHandler(async (event) => {
  const data = await readFormData(event);
  // send an email, save to a database...
  return { ok: true, name: data.get("name") };
});

Saved data (localStorage)#

Key Shape Notes
overtone-player { "id": 18, "rate": 1.25, "volume": 0.9, "muted": false, "playing": true } The loaded episode and player settings. Unknown ids and speeds not in RATES are ignored on read, and the volume is clamped to 0–1. Without id, nothing is loaded
overtone-progress { "18": 1720.4, "62": 312 } Seconds listened per episode id. Positions within 2 seconds of the end count as played and restart from the top

The keys are STORE_PLAYER and STORE_PROGRESS in app/utils/player.ts. Values are read with readJSON(), which falls back to defaults on missing or broken data, and written with writeJSON(). Both go through safeStorage (app/utils/safeStorage.ts), which keeps the values in memory when the browser blocks storage or it is full, so playback and progress still work for the page view. To reset, run localStorage.removeItem("overtone-player"); localStorage.removeItem("overtone-progress") in the browser console.

SEO and sharing#

Each page calls usePageSeo() (app/composables/usePageSeo.ts) with its title and description, which adds Open Graph and Twitter tags, a canonical link and an absolute og:image. Site-wide tags (favicon, theme-color, color-scheme) are in app.head in nuxt.config.ts. Set your domain with SITE_URL when building so canonical and social image URLs point at it:

sh
SITE_URL=https://www.yourshow.com pnpm generate

The episode page's generated HTML describes the newest episode; the browser updates the title and description for the episode in ?id=. If search visibility for each episode matters, generate one route per episode instead (for example app/pages/episodes/[id].vue, with every id listed in routes in nuxt.config.ts). Consider adding a sitemap and robots.txt (in public/), PodcastEpisode structured data (JSON-LD with useHead()) and a <link rel="alternate" type="application/rss+xml"> to SHOW.feed in app.head so podcast apps can find your feed. The 404 page is noindex.

Accessibility#

  • Skip link, landmarks (header, nav, main, footer, labeled aside and regions) and one h1 per page; the breadcrumb uses aria-current.
  • Visible focus rings in lilac on every control, including the invisible seek slider (the waveform gets the ring).
  • The player is fully keyboard operable, with labeled buttons, a slider with spoken values and documented shortcuts (aria-keyshortcuts). When the mini player hides with focus inside it, focus moves to the main content.
  • Filters use aria-pressed, the current chapter and transcript line use aria-current, result counts, the toast and form errors are live regions.
  • Reduced motion turns off scroll reveals, the ticker, equalizer and ripple animations, and smooth scrolling.
  • Text colors meet WCAG AA on every surface.

When editing, keep one h1 per page, give new inputs a <label> and an error element with the matching id, and keep alt text on replaced images.

Building and deploying#

pnpm generate writes a static site to .output/public/: every page in routes (nuxt.config.ts) as episodes.html, episode.html and so on, plus 404.html. Upload that folder to any static host: Netlify, Vercel, Cloudflare Pages, GitHub Pages or your own server. Most hosts serve episodes.html at /episodes. Audio files can live in public/audio/ or on your podcast host.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building (the live demo uses /demos/nuxt/echo); links, assets, fonts and audio files in public/ all get the prefix:

sh
DEMO_BASE_PATH=/podcast pnpm generate

pnpm build makes a Node.js server build in .output/ instead (start it with node .output/server/index.mjs), which you need for server routes like the form example above.

TypeScript setup#

tsconfig.json only references the configs Nuxt writes to .nuxt/, so run pnpm install (or pnpm typecheck) once before your editor or ESLint can check the project.

Troubleshooting#

  • Nothing plays when I add a src. Check the path (files in public/ are written without a leading slash, like audio/episode-63.mp3), that the file is MP3, AAC or another format the browser plays, and that your audio host allows it (some hosts block hotlinking). Errors show a toast.
  • The player keeps resuming an old episode. That is the saved state. Clear it with the commands in Saved data, or press the mini player's close button.
  • "Copy link" doesn't copy. The clipboard API needs https or localhost; elsewhere the template falls back to selecting the link so it can be copied by hand.
  • A new page is missing from the generated site. Add its path to routes in nuxt.config.ts; pages are not discovered by crawling links.
  • /episode?id=... works locally but 404s on my host. The host must serve episode.html for /episode. Most do; on others link to /episode.html?id=... or add a rewrite.
  • Times or chapters are wrong after replacing audio. Update duration and the chapter t values in DETAILS, all in seconds.
  • ESLint says .nuxt/eslint.config.mjs is missing. Run pnpm install or pnpm typecheck first; both run nuxt prepare.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Schibsted Grotesk, Syne and DM Mono are under the SIL Open Font License (app/assets/fonts/OFL.txt). The show, its hosts, guests, listeners and reviews are fictional.

Search documentation

Search the guides and every template's documentation