Replace Spotify embeds with branded custom audio player backed by RSS

- Add /api/episode-audio route returning MP3 URLs from Anchor RSS feed
- Replace Spotify URL text inputs in admin with RSS episode dropdowns
- New EpisodeAudioPlayer component with podcast art, show name, progress
  bar, and Spotify icon link — full and compact sizes
- Backward-compatible: legacy Spotify embed URLs still render as iframes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-17 13:23:19 -04:00
parent 3d8f02d915
commit a1d5889711
6 changed files with 393 additions and 25 deletions
+34 -5
View File
@@ -15,9 +15,10 @@ interface SortableLessonSectionProps {
study: StudyProgram
updateStudySection: (studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | string[] | number) => void
removeStudySection: (studyId: string, sectionId: string) => void
rssEpisodes: Array<{ title: string; audioUrl: string; duration: string; episode: string }>
}
function SortableLessonSection({ section, study, updateStudySection, removeStudySection }: SortableLessonSectionProps) {
function SortableLessonSection({ section, study, updateStudySection, removeStudySection, rssEpisodes }: SortableLessonSectionProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
const style = {
transform: CSS.Transform.toString(transform),
@@ -60,8 +61,16 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Episode Audio (optional)</label>
<select id={`study-section-audio-${study.id}-${section.id}`} value={section.audioEmbedUrl ?? ''} onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)}>
<option value=""> No audio </option>
{rssEpisodes.map(ep => (
<option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>
))}
{section.audioEmbedUrl && !rssEpisodes.some(ep => ep.audioUrl === section.audioEmbedUrl) && (
<option value={section.audioEmbedUrl}>{section.audioEmbedUrl}</option>
)}
</select>
</div>
<div className="admin-field">
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
@@ -1102,6 +1111,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [qrEditLabel, setQrEditLabel] = useState('')
const [qrEditDest, setQrEditDest] = useState('')
const [rssEpisodes, setRssEpisodes] = useState<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([])
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
@@ -1237,6 +1248,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
useEffect(() => {
fetch('/api/episode-audio')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes: Array<{ title: string; audioUrl: string; duration: string; episode: string }> }) => {
setRssEpisodes(data.episodes ?? [])
})
.catch(() => {})
}, [])
useEffect(() => {
fetch('/api/admin-auth/status')
.then(r => r.ok ? r.json() : Promise.reject())
@@ -3913,8 +3933,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional enables in-page player)</label>
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
<label htmlFor={`podcast-embed-${item.id}`}>Episode Audio (optional enables in-page player)</label>
<select id={`podcast-embed-${item.id}`} value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)}>
<option value=""> No audio </option>
{rssEpisodes.map(ep => (
<option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>
))}
{item.embedUrl && !rssEpisodes.some(ep => ep.audioUrl === item.embedUrl) && (
<option value={item.embedUrl}>{item.embedUrl}</option>
)}
</select>
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
@@ -4775,6 +4803,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
study={study}
updateStudySection={updateStudySection}
removeStudySection={removeStudySection}
rssEpisodes={rssEpisodes}
/>
))}
</SortableContext>