Safari hands web pages one voice per language, whatever the iPhone has installed, so the picker cannot fix that on iOS. It now carries the way out instead: Accessibility -> Spoken Content -> Voices -> your language, download an Enhanced/Premium voice and make it preferred. The speaker button wears an amber dot while the note is waiting, and the voice-less banner appends the same guidance. Detection covers iPadOS too (maxTouchPoints, since it reports as a Mac). The suite fakes an iPhone user agent, a single Daniel voice and pins the dot and the note's actual steps (37 checks). Version 0.7.5, SW v97.
625 lines
24 KiB
TypeScript
625 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { useI18n } from "@/lib/i18n";
|
||
import {
|
||
createPodcastSpeech,
|
||
tokenizeSections,
|
||
type PodcastVoice,
|
||
} from "@/lib/podcast-speech";
|
||
import { savePodcastProgress } from "@/lib/podcasts";
|
||
import {
|
||
bestVoice,
|
||
getStoredVoice,
|
||
isAppleMobile,
|
||
onlyRoboticVoices,
|
||
platformVoices,
|
||
setStoredVoice,
|
||
voicesFor,
|
||
} from "@/lib/podcast-voices";
|
||
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
|
||
import type { Podcast } from "@/lib/schemas";
|
||
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
||
import {
|
||
CheckCircleIcon,
|
||
ChevronLeftIcon,
|
||
ClockIcon,
|
||
PauseIcon,
|
||
PlayIcon,
|
||
RefreshIcon,
|
||
SpeakerIcon,
|
||
} from "@/components/icons";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
/** Playback speeds, cycled by one button. */
|
||
const RATES = [1, 1.15, 1.3, 0.9] as const;
|
||
|
||
/**
|
||
* Reads an episode aloud and highlights the word being spoken — the transcript
|
||
* is the player, Spotify-lyrics style: past words stay dark, upcoming words
|
||
* are dimmed, the word under the voice rides a rounded highlight that keeps
|
||
* itself centred as the episode plays.
|
||
*
|
||
* The heavy lifting (boundary events, the timer fallback, seeking by
|
||
* re-speaking) lives in lib/podcast-speech; this component owns the layout,
|
||
* the controls and the saved position.
|
||
*/
|
||
export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||
const { t, plural, locale } = useI18n();
|
||
const reduceMotion = useReducedMotion();
|
||
const [rate, setRate] = useState<number>(1);
|
||
const [playing, setPlaying] = useState(false);
|
||
const [position, setPosition] = useState<PodcastVoice | null>(null);
|
||
const [unsupported, setUnsupported] = useState(false);
|
||
const [voiceOpen, setVoiceOpen] = useState(false);
|
||
const transcript = useRef<HTMLDivElement>(null);
|
||
const activeWord = useRef<HTMLElement | null>(null);
|
||
/** Line-following state: the last followed line and a manual-scroll pause. */
|
||
const lastLineTop = useRef<number | null>(null);
|
||
const followPausedUntil = useRef(0);
|
||
const voiceLocale = useMemo(
|
||
() => speechLocale(episode.language, locale),
|
||
[episode.language, locale],
|
||
);
|
||
const [voiceList, setVoiceList] = useState<SpeechSynthesisVoice[]>([]);
|
||
const [voiceURI, setVoiceURI] = useState<string | null>(() =>
|
||
getStoredVoice(voiceLocale),
|
||
);
|
||
|
||
/* The voice catalogue arrives asynchronously (and changes when the OS
|
||
installs one), so subscribe rather than read it once. */
|
||
useEffect(() => {
|
||
const read = () => setVoiceList(platformVoices());
|
||
read();
|
||
try {
|
||
window.speechSynthesis?.addEventListener("voiceschanged", read);
|
||
} catch {
|
||
/* no voices API */
|
||
}
|
||
return () => {
|
||
try {
|
||
window.speechSynthesis?.removeEventListener("voiceschanged", read);
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const availableVoices = useMemo(
|
||
() => voicesFor(voiceLocale, voiceList),
|
||
[voiceLocale, voiceList],
|
||
);
|
||
const voice = useMemo(() => {
|
||
if (voiceURI) {
|
||
const match = availableVoices.find(
|
||
(entry) => entry.voiceURI === voiceURI,
|
||
);
|
||
if (match) return match;
|
||
}
|
||
return bestVoice(voiceLocale, voiceList);
|
||
}, [voiceURI, availableVoices, voiceLocale, voiceList]);
|
||
const roboticOnly = useMemo(
|
||
() => onlyRoboticVoices(voiceLocale, voiceList),
|
||
[voiceLocale, voiceList],
|
||
);
|
||
/* iOS hands the page one voice per language; point at the settings that
|
||
change it (the note is the whole reason this section exists there). */
|
||
const iosLimited = useMemo(
|
||
() => isAppleMobile() && availableVoices.length <= 1,
|
||
[availableVoices.length],
|
||
);
|
||
/** Latest position for the unmount/pause save, without re-creating callbacks. */
|
||
const latest = useRef<PodcastVoice | null>(episode.progress ?? null);
|
||
|
||
const tokens = useMemo(
|
||
() => tokenizeSections(episode.sections),
|
||
[episode.sections],
|
||
);
|
||
const supported = useMemo(() => speechAvailable(), []);
|
||
|
||
const speech = useMemo(() => {
|
||
if (!supported) return null;
|
||
return createPodcastSpeech({
|
||
sections: episode.sections,
|
||
locale: voiceLocale,
|
||
rate,
|
||
voice,
|
||
onWord: (next) => {
|
||
latest.current = next;
|
||
setPosition(next);
|
||
},
|
||
onPlay: () => {
|
||
setPlaying(true);
|
||
/* The Aero soundtrack steps aside for the voice (see AeroMusic). */
|
||
window.dispatchEvent(
|
||
new CustomEvent("freezmo:foreground-audio", { detail: "podcast" }),
|
||
);
|
||
},
|
||
onPause: () => setPlaying(false),
|
||
onEnd: () => {
|
||
setPlaying(false);
|
||
setPosition(null);
|
||
latest.current = null;
|
||
savePodcastProgress(episode.id, { section: 0, word: 0 });
|
||
},
|
||
onError: (message) => {
|
||
if (message === "unsupported") setUnsupported(true);
|
||
setPlaying(false);
|
||
},
|
||
});
|
||
}, [episode.id, episode.sections, rate, supported, voiceLocale, voice]);
|
||
|
||
/* Resume where the listener left off (a reopened episode starts there). */
|
||
useEffect(() => {
|
||
if (episode.progress) {
|
||
latest.current = {
|
||
section: episode.progress.section,
|
||
word: episode.progress.word,
|
||
};
|
||
setPosition({
|
||
section: episode.progress.section,
|
||
word: episode.progress.word,
|
||
});
|
||
}
|
||
}, [episode.id, episode.progress]);
|
||
|
||
const persist = useCallback(() => {
|
||
if (latest.current) savePodcastProgress(episode.id, latest.current);
|
||
}, [episode.id]);
|
||
|
||
/* The engine instance is rebuilt on a speed change; keep a ref so the
|
||
unmount cleanup always stops whichever one is current. */
|
||
const speechRef = useRef(speech);
|
||
const playingRef = useRef(false);
|
||
useEffect(() => {
|
||
speechRef.current = speech;
|
||
}, [speech]);
|
||
useEffect(() => {
|
||
playingRef.current = playing;
|
||
}, [playing]);
|
||
useEffect(() => {
|
||
return () => {
|
||
persist();
|
||
speechRef.current?.stop();
|
||
};
|
||
}, [persist]);
|
||
|
||
/* Closing the tab, switching apps or hard-navigating away: the unmount
|
||
cleanup never runs there, so the position also rides the page lifecycle. */
|
||
useEffect(() => {
|
||
const save = () => persist();
|
||
const onHide = () => {
|
||
if (document.visibilityState === "hidden") save();
|
||
};
|
||
window.addEventListener("pagehide", save);
|
||
document.addEventListener("visibilitychange", onHide);
|
||
return () => {
|
||
window.removeEventListener("pagehide", save);
|
||
document.removeEventListener("visibilitychange", onHide);
|
||
};
|
||
}, [persist]);
|
||
|
||
/* A rebuilt engine (speed change) resumes the same word when it was
|
||
playing — the listener should not notice the swap. */
|
||
const firstEngine = useRef(true);
|
||
useEffect(() => {
|
||
if (firstEngine.current) {
|
||
firstEngine.current = false;
|
||
return;
|
||
}
|
||
if (!playingRef.current || !speech) return;
|
||
speech.play(latest.current ?? { section: 0, word: 0 });
|
||
}, [speech]);
|
||
|
||
/* The highlight: one class on the active word (imperative, so a few hundred
|
||
spans do not re-render per word). The scroll follows by *line*: words on
|
||
the same line never move anything, a line only scrolls once it leaves the
|
||
comfortable band, and a manual scroll pauses the following for a moment
|
||
instead of fighting the reader. */
|
||
useEffect(() => {
|
||
const container = transcript.current;
|
||
if (!container) return;
|
||
activeWord.current?.removeAttribute("data-word-active");
|
||
activeWord.current?.classList.remove("podcast-word-active");
|
||
if (!position) {
|
||
activeWord.current = null;
|
||
return;
|
||
}
|
||
const index = tokens.findIndex(
|
||
(token) =>
|
||
token.section === position.section && token.index === position.word,
|
||
);
|
||
if (index === -1) return;
|
||
const element = container.querySelector<HTMLElement>(
|
||
`[data-word="${index}"]`,
|
||
);
|
||
if (!element) return;
|
||
element.setAttribute("data-word-active", "1");
|
||
element.classList.add("podcast-word-active");
|
||
activeWord.current = element;
|
||
|
||
const box = element.getBoundingClientRect();
|
||
const view = container.getBoundingClientRect();
|
||
const top = box.top - view.top;
|
||
/* Same line as last time (a wrapped word shifts a pixel or two): nothing
|
||
to do — this is what stops the jitter under every word. */
|
||
const previous = lastLineTop.current;
|
||
const sameLine = previous !== null && Math.abs(top - previous) < 8;
|
||
lastLineTop.current = top;
|
||
if (sameLine) return;
|
||
/* The reader scrolled away on purpose: let them read for a moment. */
|
||
if (Date.now() < followPausedUntil.current) return;
|
||
|
||
const comfortable = view.height * 0.42;
|
||
const delta = top - comfortable;
|
||
/* Inside the band already: leave the page alone. */
|
||
if (Math.abs(delta) < view.height * 0.14) return;
|
||
container.scrollTo({
|
||
top: container.scrollTop + delta,
|
||
behavior: Math.abs(delta) > 80 && !reduceMotion ? "smooth" : "auto",
|
||
});
|
||
}, [position, tokens, reduceMotion]);
|
||
|
||
/* A wheel, a drag or a touch on the transcript is the reader taking over. */
|
||
useEffect(() => {
|
||
const container = transcript.current;
|
||
if (!container) return;
|
||
const suspend = () => {
|
||
followPausedUntil.current = Date.now() + 4000;
|
||
};
|
||
container.addEventListener("wheel", suspend, { passive: true });
|
||
container.addEventListener("touchstart", suspend, { passive: true });
|
||
container.addEventListener("pointerdown", suspend, { passive: true });
|
||
return () => {
|
||
container.removeEventListener("wheel", suspend);
|
||
container.removeEventListener("touchstart", suspend);
|
||
container.removeEventListener("pointerdown", suspend);
|
||
};
|
||
}, []);
|
||
|
||
const sections = episode.sections;
|
||
const words = tokens.length;
|
||
const done = useMemo(() => {
|
||
if (!position) return 0;
|
||
return tokens.findIndex(
|
||
(token) =>
|
||
token.section === position.section && token.index === position.word,
|
||
);
|
||
}, [position, tokens]);
|
||
|
||
const toggle = () => {
|
||
if (!speech) return;
|
||
if (playing) {
|
||
speech.pause();
|
||
persist();
|
||
return;
|
||
}
|
||
/* Other readers (a card's read-aloud) yield to the episode. */
|
||
stopSpeaking();
|
||
speech.play(position ?? { section: 0, word: 0 });
|
||
};
|
||
|
||
const seek = (next: PodcastVoice) => {
|
||
if (!speech) return;
|
||
latest.current = next;
|
||
setPosition(next);
|
||
/* A seek is an instruction to show that place: follow it at once. */
|
||
lastLineTop.current = null;
|
||
followPausedUntil.current = 0;
|
||
stopSpeaking();
|
||
speech.play(next);
|
||
};
|
||
|
||
const jump = (section: number) => seek({ section, word: 0 });
|
||
|
||
const jumpWord = (index: number) => {
|
||
const token = tokens[index];
|
||
if (!token) return;
|
||
seek({ section: token.section, word: token.index });
|
||
};
|
||
|
||
/* Word spans, built once per section: the highlight is a class flip. */
|
||
const rendered = useMemo(() => {
|
||
return sections.map((section, sectionIndex) => {
|
||
const sectionTokens = tokens.filter(
|
||
(token) => token.section === sectionIndex,
|
||
);
|
||
const nodes: React.ReactNode[] = [];
|
||
let cursor = 0;
|
||
sectionTokens.forEach((token) => {
|
||
if (token.start > cursor)
|
||
nodes.push(section.text.slice(cursor, token.start));
|
||
const globalIndex = token.global;
|
||
nodes.push(
|
||
<span
|
||
key={`${token.start}`}
|
||
data-word={globalIndex}
|
||
className="podcast-word"
|
||
>
|
||
{section.text.slice(token.start, token.end)}
|
||
</span>,
|
||
);
|
||
cursor = token.end;
|
||
});
|
||
if (cursor < section.text.length) nodes.push(section.text.slice(cursor));
|
||
return { section, sectionIndex, nodes };
|
||
});
|
||
}, [sections, tokens]);
|
||
|
||
const currentSection = position?.section ?? 0;
|
||
|
||
return (
|
||
<div className="frost-bg fixed inset-0 z-[45] flex flex-col fade-in-mobile">
|
||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||
<div className="study-chrome mx-auto flex max-w-3xl items-center gap-2.5 rounded-full px-2.5 py-2">
|
||
<Link
|
||
href="/?tab=podcasts"
|
||
aria-label={t("feed.back")}
|
||
title={t("feed.back")}
|
||
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||
>
|
||
<ChevronLeftIcon className="size-4" />
|
||
</Link>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
|
||
{episode.title}
|
||
</p>
|
||
<p className="truncate text-[11.5px] text-[var(--text-muted)]">
|
||
{t("podcast.sectionOf", {
|
||
n: Math.min(currentSection + 1, sections.length),
|
||
total: sections.length,
|
||
})}
|
||
{" · "}
|
||
{sections[currentSection]?.heading ?? ""}
|
||
</p>
|
||
</div>
|
||
{availableVoices.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setVoiceOpen((open) => !open)}
|
||
aria-label={t("podcast.voiceAria")}
|
||
aria-expanded={voiceOpen}
|
||
title={voice?.name ?? t("podcast.voiceAria")}
|
||
className={cn(
|
||
"relative grid size-10 shrink-0 place-items-center rounded-full transition",
|
||
voiceOpen
|
||
? "bg-[var(--nav-active-bg)] text-[var(--text)]"
|
||
: "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||
)}
|
||
>
|
||
<SpeakerIcon className="size-4" />
|
||
{iosLimited ? (
|
||
<span
|
||
aria-hidden
|
||
className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-amber-400 ring-2 ring-[var(--page)]"
|
||
/>
|
||
) : null}
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const index = RATES.indexOf(rate as (typeof RATES)[number]);
|
||
setRate(RATES[(index + 1) % RATES.length]);
|
||
}}
|
||
aria-label={t("podcast.speedAria")}
|
||
title={t("podcast.speedAria")}
|
||
className="shrink-0 rounded-full bg-[var(--surface-sunken)] px-3 py-2 text-[12px] font-extrabold tabular-nums text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||
>
|
||
{rate}×
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div
|
||
ref={transcript}
|
||
data-testid="podcast-transcript"
|
||
onClick={(event) => {
|
||
/* Word taps seek: the transcript is the scrubber, like lyrics. */
|
||
const target = event.target as HTMLElement;
|
||
if (!target.classList.contains("podcast-word")) return;
|
||
const index = Number(target.dataset.word);
|
||
if (Number.isInteger(index)) jumpWord(index);
|
||
}}
|
||
className="no-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-40 pt-24"
|
||
>
|
||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||
{!supported || unsupported ? (
|
||
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
|
||
{t("podcast.noVoice")}
|
||
{isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="card space-y-2 p-5">
|
||
<h1 className="text-[22px] font-extrabold leading-tight tracking-[-0.01em] text-[var(--text)]">
|
||
{episode.title}
|
||
</h1>
|
||
{episode.summary ? (
|
||
<p className="text-[13.5px] leading-relaxed text-[var(--text-muted)]">
|
||
{episode.summary}
|
||
</p>
|
||
) : null}
|
||
<p className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-subtle)]">
|
||
<ClockIcon className="size-3.5" />
|
||
{plural(episode.sections.length, "podcast.sectionCount")} ·{" "}
|
||
{plural(words, "podcast.wordCount")}
|
||
</p>
|
||
</div>
|
||
|
||
{/* The script reads on a glass pane: in Aero the wallpaper behind
|
||
plain paragraphs is bright grass and sun (the contrast scan caught
|
||
it), and the same treatment keeps every theme consistent. */}
|
||
<div className="card space-y-6 p-5" data-testid="podcast-script">
|
||
{rendered.map(({ section, sectionIndex, nodes }) => (
|
||
<section key={sectionIndex} className="space-y-2">
|
||
<h2 className="flex items-center gap-2 text-[12px] font-bold uppercase tracking-wide text-[var(--text-subtle)]">
|
||
<span className="grid size-6 place-items-center rounded-full bg-[var(--surface-sunken)] text-[11px] tabular-nums">
|
||
{sectionIndex + 1}
|
||
</span>
|
||
{section.heading}
|
||
</h2>
|
||
<p
|
||
className={cn(
|
||
"text-[17px] leading-[1.85] text-[var(--text-muted)] transition-colors sm:text-[18px]",
|
||
sectionIndex === currentSection && "text-[var(--text)]",
|
||
)}
|
||
>
|
||
{nodes}
|
||
</p>
|
||
</section>
|
||
))}
|
||
</div>
|
||
|
||
<p className="pb-6 text-center text-[11.5px] text-[var(--text-subtle)]">
|
||
{t("podcast.tapToJump")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Controls: chapter list on the left, transport in the middle. */}
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 pb-safe">
|
||
<div className="pointer-events-auto mx-auto max-w-3xl px-3 pb-3">
|
||
{voiceOpen ? (
|
||
<div className="pointer-events-auto mx-auto mb-2 w-full max-w-md px-0">
|
||
<div className="glass-panel max-h-72 space-y-1 overflow-y-auto overscroll-contain p-3">
|
||
<div className="flex items-center justify-between gap-2 px-1">
|
||
<p className="text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||
{t("podcast.voiceAria")}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setVoiceOpen(false)}
|
||
className="text-[12.5px] font-semibold text-accent-700 dark:text-accent-200"
|
||
>
|
||
{t("common.close")}
|
||
</button>
|
||
</div>
|
||
{voiceURI ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setVoiceURI(null);
|
||
window.localStorage.removeItem(
|
||
"freezmo.podcast-voice.v1",
|
||
);
|
||
}}
|
||
className="nav-row touch-target w-full text-left text-[13px]"
|
||
>
|
||
{t("podcast.voiceAuto")}
|
||
</button>
|
||
) : null}
|
||
{availableVoices.map((entry) => {
|
||
const active = entry.voiceURI === voice?.voiceURI;
|
||
return (
|
||
<button
|
||
key={entry.voiceURI}
|
||
type="button"
|
||
aria-pressed={active}
|
||
onClick={() => {
|
||
setVoiceURI(entry.voiceURI);
|
||
setStoredVoice(voiceLocale, entry.voiceURI);
|
||
setVoiceOpen(false);
|
||
}}
|
||
className={cn(
|
||
"nav-row touch-target flex w-full items-center gap-2 text-left text-[13px]",
|
||
active &&
|
||
"border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
|
||
)}
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate font-medium text-[var(--text)]">
|
||
{entry.name}
|
||
</span>
|
||
<span className="block text-[11.5px] text-[var(--text-subtle)]">
|
||
{entry.lang}
|
||
{entry.localService
|
||
? ` · ${t("podcast.voiceLocal")}`
|
||
: ""}
|
||
</span>
|
||
</span>
|
||
{active ? (
|
||
<CheckCircleIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
|
||
{iosLimited
|
||
? t("podcast.voiceIos")
|
||
: roboticOnly
|
||
? t("podcast.voiceRobotic")
|
||
: t("podcast.voiceNote")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="study-chrome rounded-[1.4rem] px-3 py-2.5">
|
||
<div
|
||
className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--track)]"
|
||
aria-hidden
|
||
>
|
||
<div
|
||
className="h-full rounded-full grad-ice transition-[width] duration-200"
|
||
style={{
|
||
width: words ? `${Math.round((done / words) * 100)}%` : "0%",
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={toggle}
|
||
disabled={!supported}
|
||
aria-label={playing ? t("podcast.pause") : t("podcast.play")}
|
||
title={playing ? t("podcast.pause") : t("podcast.play")}
|
||
className="grid size-12 shrink-0 place-items-center rounded-full grad-ice text-[#06111f] transition disabled:opacity-45"
|
||
>
|
||
{playing ? (
|
||
<PauseIcon className="size-5" />
|
||
) : (
|
||
<PlayIcon className="size-5" />
|
||
)}
|
||
</button>
|
||
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||
{sections.map((section, index) => (
|
||
<button
|
||
key={index}
|
||
type="button"
|
||
onClick={() => jump(index)}
|
||
aria-current={index === currentSection ? "true" : undefined}
|
||
className={cn(
|
||
"shrink-0 rounded-full px-3 py-2 text-[12px] font-semibold transition",
|
||
index === currentSection
|
||
? "bg-[var(--nav-active-bg)] text-[var(--text)] ring-1 ring-[var(--nav-active-border)]"
|
||
: "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||
)}
|
||
>
|
||
{section.heading}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
speech?.stop();
|
||
setPosition({ section: 0, word: 0 });
|
||
latest.current = { section: 0, word: 0 };
|
||
savePodcastProgress(episode.id, { section: 0, word: 0 });
|
||
}}
|
||
aria-label={t("podcast.restart")}
|
||
title={t("podcast.restart")}
|
||
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||
>
|
||
<RefreshIcon className="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|