@@ -4,9 +4,10 @@ import FormattedDate from "@/components/FormattedDate.astro";
44import Link from " @/components/Link.astro" ;
55import MDContent from " @/components/MDContent.astro" ;
66import TalkSlides from " @/components/TalkSlides.astro" ;
7- import AudioTranscript from " @/components/AudioTranscript .astro" ;
7+ import TUIAudioPlayer from " @/components/TUIAudioPlayer .astro" ;
88import Layout from " @/layouts/Layout.astro" ;
99import { Code } from " @/utils/code" ;
10+ import { resolveAssetUrl } from " @/utils/assets" ;
1011
1112export async function getStaticPaths() {
1213 const talks = await getCollection (" talks" );
@@ -27,6 +28,15 @@ const hasSlides = talk.data.slides !== undefined;
2728const hasAudio = talk .data .audioPath !== undefined ;
2829const hasTranscript = talk .data .transcriptPath !== undefined ;
2930const 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