Skip to content

Commit 8ba5441

Browse files
committed
feat(youtube): implement playback position tracking and resume
- Integrated YouTube IFrame API into Reader and ArticleItem. - Automatically save playback position to database every 5 seconds during video playback. - Support resuming videos from the last saved position using the 'start' parameter. - Refactored YouTube embedding to use API-controlled players instead of static iframes.
1 parent 24a92f0 commit 8ba5441

3 files changed

Lines changed: 179 additions & 36 deletions

File tree

146 KB
Loading

web/src/components/ArticleItem.tsx

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,74 @@ function ArticleItemComponent({ article, feed, isSelected, onToggleRead, onToggl
135135
};
136136

137137
const handleVideoClick = (e: React.MouseEvent) => {
138-
// Desktop Check: If screen is wide (>= 1024px), let it click through to the detailed reader view
139-
if (window.matchMedia('(min-width: 1024px)').matches) {
140-
return; // Allow default Link behavior
138+
e.preventDefault();
139+
e.stopPropagation();
140+
setIsVideoPlaying(true);
141+
142+
// Load YouTube API if not present
143+
if (!window.YT) {
144+
const tag = document.createElement('script');
145+
tag.src = "https://www.youtube.com/iframe_api";
146+
const firstScriptTag = document.getElementsByTagName('script')[0];
147+
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
141148
}
149+
};
142150

143-
// Mobile/Tablet: Play Inline
144-
if (article.mediaKind === 'youtube' && videoId) {
145-
e.preventDefault();
146-
e.stopPropagation();
147-
setIsVideoPlaying(true);
151+
const videoRef = useRef<HTMLDivElement>(null);
152+
const playerRef = useRef<any>(null);
153+
154+
useEffect(() => {
155+
if (isVideoPlaying && article.mediaKind === 'youtube' && videoId && videoRef.current) {
156+
const initPlayer = () => {
157+
playerRef.current = new window.YT.Player(videoRef.current, {
158+
videoId: videoId,
159+
playerVars: {
160+
autoplay: 1,
161+
playsinline: 1,
162+
modestbranding: 1,
163+
rel: 0,
164+
start: Math.floor(article.playbackPosition || 0),
165+
},
166+
events: {
167+
onStateChange: (event: any) => {
168+
if (event.data === window.YT.PlayerState.PLAYING) {
169+
const interval = setInterval(() => {
170+
if (playerRef.current?.getCurrentTime) {
171+
db.articles.update(article.id, { playbackPosition: playerRef.current.getCurrentTime() });
172+
}
173+
}, 5000);
174+
playerRef.current._posInterval = interval;
175+
} else {
176+
if (playerRef.current?._posInterval) clearInterval(playerRef.current._posInterval);
177+
if (playerRef.current?.getCurrentTime) {
178+
db.articles.update(article.id, { playbackPosition: playerRef.current.getCurrentTime() });
179+
}
180+
}
181+
}
182+
}
183+
});
184+
};
185+
186+
if (window.YT && window.YT.Player) initPlayer();
187+
else {
188+
const prev = window.onYouTubeIframeAPIReady;
189+
window.onYouTubeIframeAPIReady = () => {
190+
if (prev) prev();
191+
initPlayer();
192+
};
193+
}
148194
}
149-
};
195+
196+
return () => {
197+
if (playerRef.current) {
198+
if (playerRef.current._posInterval) clearInterval(playerRef.current._posInterval);
199+
playerRef.current.destroy();
200+
playerRef.current = null;
201+
}
202+
};
203+
}, [isVideoPlaying, videoId, article.id]);
204+
205+
const isLocal = isNaN(parseInt(article.id));
150206

151207
return (
152208
<ArticleSwipeRow
@@ -257,13 +313,17 @@ function ArticleItemComponent({ article, feed, isSelected, onToggleRead, onToggl
257313
{/* Show embedded video on mobile when playing */}
258314
{isVideoPlaying && videoId ? (
259315
<div className="w-full h-full bg-black rounded-lg overflow-hidden">
260-
<iframe
261-
src={`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&playsinline=1&modestbranding=1&rel=0`}
262-
className="w-full h-full"
263-
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
264-
allowFullScreen
265-
style={{ border: 0, minHeight: '180px' }}
266-
/>
316+
{article.mediaKind === 'youtube' ? (
317+
<div ref={videoRef} className="w-full h-full" />
318+
) : (
319+
<iframe
320+
src={`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&playsinline=1&modestbranding=1&rel=0`}
321+
className="w-full h-full"
322+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
323+
allowFullScreen
324+
style={{ border: 0, minHeight: '180px' }}
325+
/>
326+
)}
267327
</div>
268328
) : (
269329
<img

web/src/components/Reader.tsx

Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -211,33 +211,18 @@ export function Reader({ article }: ReaderProps) {
211211
} catch (e) { }
212212

213213
if (videoId) {
214-
const iframeHtml = `
215-
<div style="margin-bottom: 24px;">
216-
<iframe
217-
width="100%"
218-
height="auto"
219-
style="aspect-ratio: 16/9; border-radius: 12px;"
220-
src="https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1&modestbranding=1&rel=0"
221-
frameborder="0"
222-
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
223-
allowfullscreen>
224-
</iframe>
225-
</div>
226-
<hr style="margin: 24px 0; border-color: #3f3f46;" />
227-
`;
228-
229-
setContent(prev => {
230-
if (prev.includes(videoId!)) return prev;
231-
return iframeHtml + prev;
232-
});
214+
// We render the video separately now to allow for position tracking
215+
setYoutubeVideoId(videoId);
233216
}
234217
}
235218
};
236219

237220
// Defer enhancement to next tick to allow initial render
238221
const timer = setTimeout(enhanceContent, 0);
239222
return () => clearTimeout(timer);
240-
}, [article.id, article.url, article.mediaKind, article.readerHTML]); // Removed 'content' dependency to avoid loops
223+
}, [article.id, article.url, article.mediaKind, article.readerHTML]);
224+
225+
const [youtubeVideoId, setYoutubeVideoId] = useState<string | null>(null);
241226

242227
const toggleReaderMode = async () => {
243228
if (isReaderMode) {
@@ -463,6 +448,17 @@ export function Reader({ article }: ReaderProps) {
463448
`}</style>
464449

465450
{/* Content */}
451+
{youtubeVideoId && (
452+
<div className="mb-8">
453+
<YouTubePlayer
454+
videoId={youtubeVideoId}
455+
articleId={article.id}
456+
initialPosition={article.playbackPosition}
457+
/>
458+
<hr className="my-8 border-zinc-200 dark:border-zinc-800" />
459+
</div>
460+
)}
461+
466462
<div
467463
className="reader-content prose prose-zinc dark:prose-invert prose-lg max-w-none"
468464
style={{ fontSize: `${zoom}%` }}
@@ -480,3 +476,90 @@ export function Reader({ article }: ReaderProps) {
480476
</div >
481477
);
482478
}
479+
480+
function YouTubePlayer({ videoId, articleId, initialPosition = 0 }: { videoId: string; articleId: string; initialPosition?: number }) {
481+
const playerRef = useRef<any>(null);
482+
const containerRef = useRef<HTMLDivElement>(null);
483+
484+
useEffect(() => {
485+
// Load YouTube IFrame API if not already loaded
486+
if (!window.YT) {
487+
const tag = document.createElement('script');
488+
tag.src = "https://www.youtube.com/iframe_api";
489+
const firstScriptTag = document.getElementsByTagName('script')[0];
490+
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
491+
}
492+
493+
const initPlayer = () => {
494+
playerRef.current = new window.YT.Player(containerRef.current, {
495+
videoId: videoId,
496+
playerVars: {
497+
autoplay: 1,
498+
playsinline: 1,
499+
modestbranding: 1,
500+
rel: 0,
501+
start: Math.floor(initialPosition),
502+
},
503+
events: {
504+
onStateChange: (event: any) => {
505+
// When video is playing, periodically save position
506+
if (event.data === window.YT.PlayerState.PLAYING) {
507+
const interval = setInterval(() => {
508+
if (playerRef.current && playerRef.current.getCurrentTime) {
509+
const currentTime = playerRef.current.getCurrentTime();
510+
db.articles.update(articleId, { playbackPosition: currentTime });
511+
} else {
512+
clearInterval(interval);
513+
}
514+
}, 5000); // Save every 5 seconds
515+
516+
// Store interval on player object to clear it later
517+
(playerRef.current as any)._posInterval = interval;
518+
} else {
519+
if ((playerRef.current as any)._posInterval) {
520+
clearInterval((playerRef.current as any)._posInterval);
521+
}
522+
// Save final position when paused/ended
523+
if (playerRef.current && playerRef.current.getCurrentTime) {
524+
const currentTime = playerRef.current.getCurrentTime();
525+
db.articles.update(articleId, { playbackPosition: currentTime });
526+
}
527+
}
528+
}
529+
}
530+
});
531+
};
532+
533+
if (window.YT && window.YT.Player) {
534+
initPlayer();
535+
} else {
536+
const previousOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady;
537+
window.onYouTubeIframeAPIReady = () => {
538+
if (previousOnYouTubeIframeAPIReady) previousOnYouTubeIframeAPIReady();
539+
initPlayer();
540+
};
541+
}
542+
543+
return () => {
544+
if (playerRef.current) {
545+
if ((playerRef.current as any)._posInterval) {
546+
clearInterval((playerRef.current as any)._posInterval);
547+
}
548+
playerRef.current.destroy();
549+
}
550+
};
551+
}, [videoId, articleId]);
552+
553+
return (
554+
<div className="relative w-full aspect-video rounded-xl overflow-hidden bg-black shadow-lg">
555+
<div ref={containerRef} className="w-full h-full" />
556+
</div>
557+
);
558+
}
559+
560+
declare global {
561+
interface Window {
562+
YT: any;
563+
onYouTubeIframeAPIReady: () => void;
564+
}
565+
}

0 commit comments

Comments
 (0)