Skip to content

Commit 6e1788e

Browse files
committed
Revamp talk layout to include audio player and transcript with responsive design and fixed layout spacing adjustments
1 parent 105e559 commit 6e1788e

3 files changed

Lines changed: 260 additions & 50 deletions

File tree

src/components/TalkSlides.astro

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,18 @@ const totalSlides = resolvedSlides.length;
7777
const containerId = `talk-slides-${talkId}`;
7878
---
7979

80-
<div id={containerId} class="talk-slides not-prose h-full flex flex-col" tabindex="0">
81-
<div class="slide-container border-2 border-fg-2 relative flex-1 group" style="cursor: pointer; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center;">
80+
<div id={containerId} class="talk-slides not-prose flex flex-col h-full min-h-0" tabindex="0">
81+
<div class="slide-container border-2 border-fg-2 relative flex-1 group cursor-pointer overflow-hidden min-h-0 flex items-center justify-center">
8282
<img
8383
src={resolvedSlides[0].url}
8484
alt="Slide 1"
85-
class="slide-image w-full h-auto"
86-
style="max-width: 100%; max-height: 100%; object-fit: contain;"
85+
class="slide-image max-w-full max-h-full w-auto h-auto block flex-shrink"
8786
/>
88-
<div class="slide-nav-left" style="position: absolute; top: 0; bottom: 0; left: 0; right: 50%; display: flex; align-items: center; justify-content: flex-start; padding-left: 1ch; opacity: 0; transition: opacity 0.2s ease; pointer-events: auto; z-index: 100;">
89-
<div class="i-pixel-arrow-alt-circle-left-solid" style="width: 3rem; height: 3rem; filter: drop-shadow(0 0 10px var(--background0)); pointer-events: none;"></div>
87+
<div class="slide-nav-left absolute inset-y-0 left-0 right-1/2 flex items-center justify-start pl-[1ch] opacity-0 transition-opacity duration-200 pointer-events-auto z-100">
88+
<div class="i-pixel-arrow-alt-circle-left-solid w-12 h-12 pointer-events-none" style="filter: drop-shadow(0 0 10px var(--background0));"></div>
9089
</div>
91-
<div class="slide-nav-right" style="position: absolute; top: 0; bottom: 0; left: 50%; right: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 1ch; opacity: 0; transition: opacity 0.2s ease; pointer-events: auto; z-index: 100;">
92-
<div class="i-pixel-arrow-alt-circle-right-solid" style="width: 3rem; height: 3rem; filter: drop-shadow(0 0 10px var(--background0)); pointer-events: none;"></div>
90+
<div class="slide-nav-right absolute inset-y-0 left-1/2 right-0 flex items-center justify-end pr-[1ch] opacity-0 transition-opacity duration-200 pointer-events-auto z-100">
91+
<div class="i-pixel-arrow-alt-circle-right-solid w-12 h-12 pointer-events-none" style="filter: drop-shadow(0 0 10px var(--background0));"></div>
9392
</div>
9493
</div>
9594
</div>
@@ -209,35 +208,45 @@ const containerId = `talk-slides-${talkId}`;
209208
navRight.style.opacity = '0';
210209
});
211210

212-
// Audio sync setup
213-
if (syncWithAudio && audioPlayerId) {
214-
const audioPlayerContainer = document.getElementById(audioPlayerId);
215-
if (audioPlayerContainer) {
216-
const audio = audioPlayerContainer.querySelector('audio');
217-
if (audio) {
218-
// Sync slides with audio playback
219-
audio.addEventListener('timeupdate', () => {
220-
if (!autoSyncEnabled) return;
221-
222-
const time = audio.currentTime;
223-
const targetSlide = findSlideIndexAtTime(time);
224-
225-
if (targetSlide !== currentSlide) {
211+
// Audio sync setup - defer until DOM is ready to ensure audio element exists
212+
function setupAudioSync() {
213+
if (syncWithAudio && audioPlayerId) {
214+
const audioPlayerContainer = document.getElementById(audioPlayerId);
215+
if (audioPlayerContainer) {
216+
const audio = audioPlayerContainer.querySelector('audio');
217+
if (audio) {
218+
// Sync slides with audio playback
219+
audio.addEventListener('timeupdate', () => {
220+
if (!autoSyncEnabled) return;
221+
222+
const time = audio.currentTime;
223+
const targetSlide = findSlideIndexAtTime(time);
224+
225+
if (targetSlide !== currentSlide) {
226+
goToSlide(targetSlide, true);
227+
}
228+
});
229+
230+
// Also sync on seeking
231+
audio.addEventListener('seeked', () => {
232+
if (!autoSyncEnabled) return;
233+
234+
const time = audio.currentTime;
235+
const targetSlide = findSlideIndexAtTime(time);
226236
goToSlide(targetSlide, true);
227-
}
228-
});
229-
230-
// Also sync on seeking
231-
audio.addEventListener('seeked', () => {
232-
if (!autoSyncEnabled) return;
233-
234-
const time = audio.currentTime;
235-
const targetSlide = findSlideIndexAtTime(time);
236-
goToSlide(targetSlide, true);
237-
});
237+
});
238+
}
238239
}
239240
}
240241
}
242+
243+
// Wait for DOM to be fully loaded before setting up audio sync
244+
if (document.readyState === 'loading') {
245+
document.addEventListener('DOMContentLoaded', setupAudioSync);
246+
} else {
247+
// DOM is already loaded, setup immediately
248+
setupAudioSync();
249+
}
241250
</script>
242251

243252
<style>

src/layouts/Layout.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const { title = "Just Be", description = "The personal site of Justin Bennett" }
1919
<body class="pt-lh flex min-h-screen flex-col">
2020
<Nav />
2121
<div class="mx-auto flex w-full max-w-4xl flex-1 flex-col px-2">
22-
<main id="main-content" class="my-2lh flex-1 focus:outline-none" tabindex="-1">
22+
<main id="main-content" class="mt-2lh flex-1 focus:outline-none" tabindex="-1">
2323
<slot />
2424
</main>
2525
</div>

src/pages/talks/[code]/[slug].astro

Lines changed: 217 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import FormattedDate from "@/components/FormattedDate.astro";
44
import Link from "@/components/Link.astro";
55
import MDContent from "@/components/MDContent.astro";
66
import TalkSlides from "@/components/TalkSlides.astro";
7-
import AudioTranscript from "@/components/AudioTranscript.astro";
7+
import TUIAudioPlayer from "@/components/TUIAudioPlayer.astro";
88
import Layout from "@/layouts/Layout.astro";
99
import { Code } from "@/utils/code";
10+
import { resolveAssetUrl } from "@/utils/assets";
1011
1112
export async function getStaticPaths() {
1213
const talks = await getCollection("talks");
@@ -27,6 +28,15 @@ const hasSlides = talk.data.slides !== undefined;
2728
const hasAudio = talk.data.audioPath !== undefined;
2829
const hasTranscript = talk.data.transcriptPath !== undefined;
2930
const hasInteractiveLayout = hasSlides && hasAudio && hasTranscript;
31+
32+
// Resolve asset URLs if interactive layout
33+
let resolvedAudioUrl, resolvedTranscriptUrl;
34+
if (hasInteractiveLayout) {
35+
const normalizedAudioPath = talk.data.audioPath.replace(/^\/assets\//, '');
36+
const normalizedTranscriptPath = talk.data.transcriptPath.replace(/^\/assets\//, '');
37+
resolvedAudioUrl = await resolveAssetUrl(normalizedAudioPath) ?? talk.data.audioPath;
38+
resolvedTranscriptUrl = await resolveAssetUrl(normalizedTranscriptPath) ?? talk.data.transcriptPath;
39+
}
3040
---
3141

3242
<Layout title={`${talk.data.title} - Just Be`} description={talk.data.description}>
@@ -48,8 +58,9 @@ const hasInteractiveLayout = hasSlides && hasAudio && hasTranscript;
4858
</header>
4959

5060
{hasInteractiveLayout ? (
51-
<div class="talk-layout grid grid-cols-1 lg:grid-cols-2 gap-4 not-prose" style="margin-left: calc(-50vw + 50%); margin-right: calc(-50vw + 50%); padding-left: 1rem; padding-right: 1rem;">
52-
<div class="slides-column flex flex-col min-h-0" style="height: 60vh; lg:height: calc(100vh - 8rem);">
61+
<div id="talk-layout-container" class="talk-layout not-prose px-4" style="margin-left: calc(-50vw + 50%); margin-right: calc(-50vw + 50%);">
62+
<!-- Slides -->
63+
<div id="slides-section" class="slides-item">
5364
<TalkSlides
5465
slides={talk.data.slides}
5566
basePath={`/talks/${slug}`}
@@ -59,12 +70,21 @@ const hasInteractiveLayout = hasSlides && hasAudio && hasTranscript;
5970
/>
6071
</div>
6172

62-
<div class="transcript-column flex flex-col min-h-0" style="height: 60vh; lg:height: calc(100vh - 8rem);">
63-
<AudioTranscript
64-
audioPath={talk.data.audioPath}
65-
transcriptPath={talk.data.transcriptPath}
66-
talkId={slug}
67-
/>
73+
<!-- Audio Player -->
74+
<div class="audio-item">
75+
<TUIAudioPlayer audioPath={resolvedAudioUrl} playerId={`audio-player-${slug}`} />
76+
</div>
77+
78+
<!-- Transcript -->
79+
<div class="transcript-item">
80+
<div is-="view" class="h-full border-2 border-fg-2 bg-0">
81+
<div is-="view-content" class="transcript-container h-full overflow-y-auto">
82+
<div class="transcript-loading text-fg-2 text-center py-2">
83+
Loading transcript...
84+
</div>
85+
<div class="transcript-content hidden"></div>
86+
</div>
87+
</div>
6888
</div>
6989
</div>
7090
) : (
@@ -73,15 +93,196 @@ const hasInteractiveLayout = hasSlides && hasAudio && hasTranscript;
7393
</article>
7494
</Layout>
7595

76-
<style>{`
96+
<style is:global>
97+
/* Transcript word highlighting */
98+
.transcript-word {
99+
display: inline;
100+
transition: background-color 0.1s ease, color 0.1s ease;
101+
}
102+
103+
.transcript-word.active {
104+
background-color: var(--foreground0);
105+
color: var(--background0);
106+
}
107+
108+
.transcript-content {
109+
line-height: 1.5;
110+
padding: 1ch;
111+
}
112+
113+
/* Mobile: Vertical stack - slides, audio, transcript */
114+
@media (max-width: 1023px) {
115+
.talk-layout {
116+
display: grid;
117+
height: calc(100vh - 10lh);
118+
grid-template-columns: 1fr;
119+
grid-template-rows: 1fr auto 1fr;
120+
gap: 1lh;
121+
}
122+
123+
.talk-layout .slides-item {
124+
min-height: 0;
125+
overflow: hidden;
126+
}
127+
128+
.talk-layout .audio-item {
129+
/* Fixed height for audio player */
130+
}
131+
132+
.talk-layout .transcript-item {
133+
min-height: 0;
134+
overflow: hidden;
135+
}
136+
}
137+
138+
/* Desktop: Side-by-side with audio + transcript on right */
77139
@media (min-width: 1024px) {
78140
.talk-layout {
79-
height: calc(100vh - 8rem);
80-
grid-auto-rows: 1fr;
141+
display: grid;
142+
height: calc(100vh - 12lh);
143+
grid-template-columns: 1fr 1fr;
144+
grid-template-rows: 1fr;
145+
gap: 1rem;
146+
}
147+
148+
.talk-layout .slides-item {
149+
grid-row: 1;
150+
grid-column: 1;
151+
min-height: 0;
152+
overflow: hidden;
81153
}
82-
.talk-layout .slides-column,
83-
.talk-layout .transcript-column {
84-
height: auto;
154+
155+
.talk-layout .audio-item {
156+
grid-row: 1;
157+
grid-column: 2;
158+
align-self: start;
159+
}
160+
161+
.talk-layout .transcript-item {
162+
grid-row: 1;
163+
grid-column: 2;
164+
align-self: end;
165+
min-height: 0;
166+
height: calc(100% - 3lh); /* Full height minus audio player height and gap */
85167
}
86168
}
87-
`}</style>
169+
</style>
170+
171+
<script define:vars={{ slug, resolvedTranscriptUrl }}>
172+
const audioPlayerId = `audio-player-${slug}`;
173+
const slidesSection = document.getElementById('slides-section');
174+
const audioPlayerContainer = document.getElementById(audioPlayerId);
175+
const audio = audioPlayerContainer?.querySelector('audio');
176+
const transcriptContent = document.querySelector('.transcript-content');
177+
const transcriptLoading = document.querySelector('.transcript-loading');
178+
const transcriptContainer = document.querySelector('.transcript-container');
179+
180+
let currentWordIndex = -1;
181+
let autoscroll = true;
182+
let wordElements = [];
183+
184+
// Scroll to slides when audio plays (mobile only)
185+
if (slidesSection && audio) {
186+
audio.addEventListener('play', () => {
187+
if (window.innerWidth < 1024) {
188+
slidesSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
189+
}
190+
});
191+
}
192+
193+
// Transcript word highlighting
194+
function findWordIndexAtTime(time) {
195+
if (!wordElements || wordElements.length === 0) return -1;
196+
let left = 0;
197+
let right = wordElements.length - 1;
198+
let result = -1;
199+
while (left <= right) {
200+
const mid = Math.floor((left + right) / 2);
201+
const wordData = wordElements[mid].dataset;
202+
const wordStart = parseFloat(wordData.start);
203+
const wordEnd = parseFloat(wordData.end);
204+
if (time >= wordStart && time <= wordEnd) {
205+
return mid;
206+
} else if (time < wordStart) {
207+
right = mid - 1;
208+
} else {
209+
result = mid;
210+
left = mid + 1;
211+
}
212+
}
213+
return result;
214+
}
215+
216+
function highlightWord(index) {
217+
if (index === currentWordIndex) return;
218+
if (currentWordIndex >= 0 && wordElements[currentWordIndex]) {
219+
wordElements[currentWordIndex].classList.remove('active');
220+
}
221+
if (index >= 0 && wordElements[index]) {
222+
const wordEl = wordElements[index];
223+
wordEl.classList.add('active');
224+
if (autoscroll && transcriptContainer) {
225+
const wordRect = wordEl.getBoundingClientRect();
226+
const containerRect = transcriptContainer.getBoundingClientRect();
227+
if (wordRect.top < containerRect.top || wordRect.bottom > containerRect.bottom) {
228+
wordEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
229+
}
230+
}
231+
}
232+
currentWordIndex = index;
233+
}
234+
235+
if (audio) {
236+
audio.addEventListener('timeupdate', () => {
237+
const index = findWordIndexAtTime(audio.currentTime);
238+
highlightWord(index);
239+
});
240+
audio.addEventListener('seeked', () => {
241+
const index = findWordIndexAtTime(audio.currentTime);
242+
highlightWord(index);
243+
});
244+
}
245+
246+
// Load transcript
247+
if (resolvedTranscriptUrl && transcriptContent && transcriptLoading) {
248+
fetch(resolvedTranscriptUrl)
249+
.then(res => {
250+
if (!res.ok) throw new Error(`Failed to load transcript: ${res.status}`);
251+
return res.json();
252+
})
253+
.then(data => {
254+
const html = [];
255+
wordElements = [];
256+
data.segments.forEach((segment, segIndex) => {
257+
if (segment.words && segment.words.length > 0) {
258+
segment.words.forEach((wordData, wordIndex) => {
259+
html.push(
260+
`<span class="transcript-word text-fg-0 hocus:text-accent cursor-pointer" ` +
261+
`data-start="${wordData.start}" data-end="${wordData.end}" data-segment="${segIndex}" data-word="${wordIndex}">` +
262+
`${wordData.word}</span> `
263+
);
264+
});
265+
} else {
266+
html.push(`<span class="text-fg-0">${segment.text}</span>`);
267+
}
268+
});
269+
transcriptContent.innerHTML = html.join('');
270+
wordElements = Array.from(transcriptContent.querySelectorAll('.transcript-word'));
271+
wordElements.forEach(wordEl => {
272+
wordEl.addEventListener('click', () => {
273+
if (audio) {
274+
audio.currentTime = parseFloat(wordEl.dataset.start);
275+
if (audio.paused) audio.play();
276+
}
277+
});
278+
});
279+
transcriptLoading.classList.add('hidden');
280+
transcriptContent.classList.remove('hidden');
281+
})
282+
.catch(err => {
283+
console.error('Failed to load transcript:', err);
284+
transcriptLoading.textContent = 'Failed to load transcript.';
285+
transcriptLoading.classList.add('text-fg-1');
286+
});
287+
}
288+
</script>

0 commit comments

Comments
 (0)