diff --git a/package.json b/package.json index 5f36619..3d9a854 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.7", + "version": "1.1.8", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index 3ea7d3b..ea57903 100644 --- a/src/App.css +++ b/src/App.css @@ -3155,8 +3155,188 @@ } /* Custom audio player */ -/* ── Full / cinematic layout (Option A) ── */ .episode-audio-player { + background: var(--brand-black-2, #0f0f0c); + border: 1px solid rgba(201, 168, 76, 0.2); + border-radius: 10px; + padding: 1rem 1.1rem; +} + +.episode-audio-player__inner { + display: flex; + align-items: center; + gap: 14px; +} + +.episode-audio-player__art { + width: 56px; + height: 56px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; +} + +.episode-audio-player__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.episode-audio-player__meta { + display: flex; + align-items: center; + justify-content: space-between; +} + +.episode-audio-player__show-name { + font-size: 0.7rem; + color: var(--brand-gold, #c9a84c); + font-family: var(--brand-font-body, Georgia, serif); + letter-spacing: 0.02em; +} + +.episode-audio-player__spotify-link { + color: rgba(176, 164, 140, 0.5); + display: flex; + align-items: center; + transition: color 0.15s; + flex-shrink: 0; +} + +.episode-audio-player__spotify-link:hover { + color: #1DB954; +} + +.episode-audio-player__spotify-link svg { + width: 16px; + height: 16px; +} + +.episode-audio-player__title { + font-size: 0.88rem; + color: var(--brand-warm-white, #f0ead8); + margin: 0; + font-family: var(--brand-font-heading, Georgia, serif); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.episode-audio-player__controls { + display: flex; + align-items: center; + gap: 10px; + margin-top: 2px; +} + +.episode-audio-player__play { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--brand-gold, #c9a84c); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--brand-black, #0a0a08); + transition: background 0.15s; +} + +.episode-audio-player__play:hover { + background: var(--brand-gold-light, #e0c070); +} + +@keyframes buffering-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.episode-audio-player__play--buffering { + animation: buffering-pulse 1s ease-in-out infinite; +} + +.episode-audio-player__play svg { + width: 14px; + height: 14px; +} + +.episode-audio-player__track { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.episode-audio-player__bar { + position: relative; + height: 3px; + background: rgba(42, 37, 24, 0.9); + border-radius: 2px; + cursor: pointer; + outline: none; +} + +.episode-audio-player__bar:focus-visible { + box-shadow: 0 0 0 2px var(--brand-gold, #c9a84c); +} + +.episode-audio-player__fill { + height: 100%; + background: var(--brand-gold, #c9a84c); + border-radius: 2px; + pointer-events: none; +} + +.episode-audio-player__thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--brand-gold-light, #e0c070); + pointer-events: none; +} + +.episode-audio-player__times { + display: flex; + justify-content: space-between; + font-size: 0.68rem; + color: var(--brand-muted, #b0a48c); + font-family: var(--brand-font-body, Georgia, serif); +} + +/* Compact variant (study sections) */ +.episode-audio-player--compact .episode-audio-player__art { + width: 42px; + height: 42px; +} + +.episode-audio-player--compact .episode-audio-player__play { + width: 28px; + height: 28px; +} + +.episode-audio-player--compact .episode-audio-player__play svg { + width: 11px; + height: 11px; +} + +.episode-audio-player--compact .episode-audio-player__bar { + height: 2px; +} + +.episode-audio-player--compact .episode-audio-player__title { + font-size: 0.8rem; +} + +/* ── Latest episode player — centered/cinematic layout ── */ +.latest-episode-player { background: var(--brand-black-2, #0f0f0c); border: 1px solid rgba(201, 168, 76, 0.2); border-radius: 12px; @@ -3164,7 +3344,7 @@ text-align: center; } -.episode-audio-player__show-name { +.latest-episode-player__show { font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; @@ -3173,7 +3353,7 @@ margin-bottom: 0.4rem; } -.episode-audio-player__title { +.latest-episode-player__title { font-size: 1rem; color: var(--brand-warm-white, #f0ead8); margin: 0 0 1.4rem; @@ -3182,7 +3362,7 @@ text-wrap: balance; } -.episode-audio-player__play-row { +.latest-episode-player__play-row { display: flex; align-items: center; justify-content: center; @@ -3190,7 +3370,7 @@ margin-bottom: 1.1rem; } -.episode-audio-player__skip { +.latest-episode-player__skip { background: none; border: none; cursor: pointer; @@ -3203,23 +3383,16 @@ padding: 0.2rem; } -.episode-audio-player__skip:hover { - color: var(--brand-warm-white, #f0ead8); -} - -.episode-audio-player__skip svg { - width: 22px; - height: 22px; -} - -.episode-audio-player__skip span { +.latest-episode-player__skip:hover { color: var(--brand-warm-white, #f0ead8); } +.latest-episode-player__skip svg { width: 22px; height: 22px; } +.latest-episode-player__skip span { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 0.55rem; letter-spacing: 0.06em; text-transform: uppercase; } -.episode-audio-player__play { +.latest-episode-player__play { width: 58px; height: 58px; border-radius: 50%; @@ -3234,31 +3407,12 @@ transition: background 0.15s, transform 0.1s; } -.episode-audio-player__play:hover { - background: var(--brand-gold-light, #e0c070); - transform: scale(1.05); -} +.latest-episode-player__play:hover { background: var(--brand-gold-light, #e0c070); transform: scale(1.05); } +.latest-episode-player__play:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(201,168,76,0.45); } +.latest-episode-player__play--buffering { animation: buffering-pulse 1s ease-in-out infinite; } +.latest-episode-player__play svg { width: 20px; height: 20px; } -.episode-audio-player__play:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.45); -} - -@keyframes buffering-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -.episode-audio-player__play--buffering { - animation: buffering-pulse 1s ease-in-out infinite; -} - -.episode-audio-player__play svg { - width: 20px; - height: 20px; -} - -.episode-audio-player__bar { +.latest-episode-player__bar { position: relative; height: 3px; background: rgba(201, 168, 76, 0.12); @@ -3268,32 +3422,27 @@ margin-bottom: 6px; } -.episode-audio-player__bar:focus-visible { - box-shadow: 0 0 0 2px var(--brand-gold, #c9a84c); -} +.latest-episode-player__bar:focus-visible { box-shadow: 0 0 0 2px var(--brand-gold, #c9a84c); } -.episode-audio-player__fill { +.latest-episode-player__fill { position: absolute; - left: 0; - top: 0; - height: 100%; + left: 0; top: 0; height: 100%; background: var(--brand-gold, #c9a84c); border-radius: 2px; pointer-events: none; } -.episode-audio-player__thumb { +.latest-episode-player__thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); - width: 11px; - height: 11px; + width: 11px; height: 11px; border-radius: 50%; background: var(--brand-gold, #c9a84c); pointer-events: none; } -.episode-audio-player__times { +.latest-episode-player__times { display: flex; justify-content: space-between; font-size: 0.62rem; @@ -3302,13 +3451,13 @@ font-variant-numeric: tabular-nums; } -.episode-audio-player__spotify-row { +.latest-episode-player__spotify-row { margin-top: 1rem; display: flex; justify-content: center; } -.episode-audio-player__spotify-link { +.latest-episode-player__spotify-link { display: inline-flex; align-items: center; gap: 6px; @@ -3319,91 +3468,8 @@ transition: color 0.15s; } -.episode-audio-player__spotify-link:hover { - color: #1DB954; -} - -.episode-audio-player__spotify-link svg { - width: 14px; - height: 14px; - flex-shrink: 0; -} - -/* ── Compact variant (study sections) ── */ -.episode-audio-player--compact { - background: var(--brand-black-2, #0f0f0c); - border: 1px solid rgba(201, 168, 76, 0.2); - border-radius: 10px; - padding: 0.75rem 1rem; - text-align: left; -} - -.episode-audio-player--compact .episode-audio-player__inner { - display: flex; - align-items: center; - gap: 10px; -} - -.episode-audio-player--compact .episode-audio-player__body { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; -} - -.episode-audio-player--compact .episode-audio-player__title { - font-size: 0.8rem; - text-wrap: unset; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 0; - line-height: 1.2; -} - -.episode-audio-player--compact .episode-audio-player__controls { - display: flex; - align-items: center; - gap: 8px; -} - -.episode-audio-player--compact .episode-audio-player__play { - width: 28px; - height: 28px; - transform: none; -} - -.episode-audio-player--compact .episode-audio-player__play:hover { - transform: none; -} - -.episode-audio-player--compact .episode-audio-player__play svg { - width: 11px; - height: 11px; -} - -.episode-audio-player--compact .episode-audio-player__track { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.episode-audio-player--compact .episode-audio-player__bar { - height: 2px; - margin-bottom: 0; -} - -.episode-audio-player--compact .episode-audio-player__spotify-link { - font-size: 0; -} - -.episode-audio-player--compact .episode-audio-player__spotify-link svg { - width: 16px; - height: 16px; -} +.latest-episode-player__spotify-link:hover { color: #1DB954; } +.latest-episode-player__spotify-link svg { width: 14px; height: 14px; flex-shrink: 0; } .episode-detail-show-notes, .episode-detail-questions { diff --git a/src/App.tsx b/src/App.tsx index 82aa58b..992917f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1574,24 +1574,131 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) { } function LatestEpisodePlayer({ content }: { content: SiteContent }) { + const audioRef = useRef(null) const [audioUrl, setAudioUrl] = useState('') const [title, setTitle] = useState('') + const [playing, setPlaying] = useState(false) + const [buffering, setBuffering] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const playTrackedRef = useRef(false) + const listenStartRef = useRef(null) useEffect(() => { fetch('/api/episodes') .then(r => r.ok ? r.json() : Promise.reject()) .then((data: { episodes?: { title: string; audioUrl: string }[] }) => { const latest = data.episodes?.[0] - if (latest?.audioUrl) { - setAudioUrl(latest.audioUrl) - setTitle(latest.title) - } + if (latest?.audioUrl) { setAudioUrl(latest.audioUrl); setTitle(latest.title) } }) .catch(() => {}) }, []) + useEffect(() => { + const audio = audioRef.current + if (!audio) return + const onTime = () => setCurrentTime(audio.currentTime) + const onDur = () => setDuration(audio.duration) + const onEnded = () => { setPlaying(false); setBuffering(false) } + const onWait = () => setBuffering(true) + const onCan = () => setBuffering(false) + audio.addEventListener('timeupdate', onTime) + audio.addEventListener('durationchange', onDur) + audio.addEventListener('loadedmetadata', onDur) + audio.addEventListener('ended', onEnded) + audio.addEventListener('waiting', onWait) + audio.addEventListener('canplay', onCan) + return () => { + audio.removeEventListener('timeupdate', onTime) + audio.removeEventListener('durationchange', onDur) + audio.removeEventListener('loadedmetadata', onDur) + audio.removeEventListener('ended', onEnded) + audio.removeEventListener('waiting', onWait) + audio.removeEventListener('canplay', onCan) + } + }, [audioUrl]) + + function togglePlay() { + const audio = audioRef.current + if (!audio) return + if (playing) { + audio.pause(); setPlaying(false) + if (listenStartRef.current !== null) listenStartRef.current = null + } else { + audio.play().then(() => { + setPlaying(true) + listenStartRef.current = Date.now() + if (!playTrackedRef.current && title) { + playTrackedRef.current = true + fetch('/api/analytics/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }).catch(() => {}) + } + }).catch(() => {}) + } + } + + function skip(sec: number) { + const audio = audioRef.current + if (!audio) return + audio.currentTime = Math.max(0, Math.min(duration, audio.currentTime + sec)) + } + + function seek(e: React.MouseEvent) { + const audio = audioRef.current + if (!audio || !duration) return + const rect = e.currentTarget.getBoundingClientRect() + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + audio.currentTime = pct * duration + setCurrentTime(pct * duration) + } + + function fmt(s: number) { + if (!isFinite(s) || s < 0) return '0:00' + return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}` + } + + const pct = duration > 0 ? (currentTime / duration) * 100 : 0 + if (!audioUrl) return
- return + + return ( +
+