window.App = window.App || {}; App.feed = App.feed || {}; (function() { const state = App.state; let observer = null; let sentinelObserver = null; const getScroller = () => document.getElementById('feed-scroll'); const destroySlidePlayback = function(slide) { const video = slide.querySelector('.feed-video'); slide.classList.remove('is-active'); const fill = slide.querySelector('.feed-timeline-fill'); if (fill) fill.style.width = '0%'; const handle = slide.querySelector('.feed-timeline-handle'); if (handle) handle.style.left = '0%'; if (!video) return; if (video._hlsPlayer) { video._hlsPlayer.destroy(); video._hlsPlayer = null; } video.pause(); video.removeAttribute('src'); video.load(); slide.classList.remove('is-loaded'); }; const setTimelinePosition = function(slide, ratio) { const fill = slide.querySelector('.feed-timeline-fill'); const handle = slide.querySelector('.feed-timeline-handle'); const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`; if (fill) fill.style.width = pct; if (handle) handle.style.left = pct; }; const seekFromPointer = function(slide, video, timeline, clientX) { if (!isFinite(video.duration) || video.duration <= 0) return; const rect = timeline.getBoundingClientRect(); const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; const clamped = Math.min(1, Math.max(0, ratio)); video.currentTime = clamped * video.duration; setTimelinePosition(slide, clamped); }; const bindTimeline = function(slide, video) { const timeline = slide.querySelector('.feed-timeline'); if (!timeline) return; let scrubbing = false; video.addEventListener('timeupdate', () => { if (scrubbing || !isFinite(video.duration) || video.duration <= 0) return; setTimelinePosition(slide, video.currentTime / video.duration); }); timeline.addEventListener('pointerdown', (event) => { scrubbing = true; timeline.classList.add('is-scrubbing'); timeline.setPointerCapture(event.pointerId); seekFromPointer(slide, video, timeline, event.clientX); event.preventDefault(); event.stopPropagation(); }); timeline.addEventListener('pointermove', (event) => { if (!scrubbing) return; seekFromPointer(slide, video, timeline, event.clientX); event.preventDefault(); event.stopPropagation(); }); const stopScrubbing = (event) => { if (!scrubbing) return; scrubbing = false; timeline.classList.remove('is-scrubbing'); if (timeline.hasPointerCapture(event.pointerId)) { timeline.releasePointerCapture(event.pointerId); } event.stopPropagation(); }; timeline.addEventListener('pointerup', stopScrubbing); timeline.addEventListener('pointercancel', stopScrubbing); }; const PRELOAD_COUNT = 2; const loadSlideSource = function(slide, videoData, autoplay) { const video = slide.querySelector('.feed-video'); if (!video) return; if (slide.classList.contains('is-loaded')) { if (autoplay) { video.muted = state.feedMuted; const playPromise = video.play(); if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); } return; } slide.classList.add('is-loaded'); const resolved = App.videos.resolveStreamSource(videoData); if (!resolved.url) return; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`; const isHls = /\.m3u8($|\?)/i.test(resolved.url); const canUseHls = !!(window.Hls && window.Hls.isSupported()); video.muted = state.feedMuted; video.preload = 'auto'; if (isHls && canUseHls) { const hls = new window.Hls(); video._hlsPlayer = hls; hls.loadSource(streamUrl); hls.attachMedia(video); hls.on(window.Hls.Events.ERROR, (event, data) => { if (data && data.fatal && video._hlsPlayer === hls) { hls.destroy(); video._hlsPlayer = null; } }); } else { video.src = streamUrl; } if (autoplay) { const playPromise = video.play(); if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); } }; const activateSlide = function(slide, videoData) { loadSlideSource(slide, videoData, true); }; // Keeps exactly [active, active+1, active+2] loaded: preloads the next // PRELOAD_COUNT slides and tears down everything else. Centralizing this // here (instead of letting each slide's own enter/leave intersection // entry decide) avoids a race where the initial batch of "not // intersecting" entries for not-yet-visible slides undoes the preload // we just triggered for them. const syncWindow = function(activeSlide) { const scroller = getScroller(); if (!scroller) return; const slides = Array.from(scroller.querySelectorAll('.feed-slide')); const index = slides.indexOf(activeSlide); if (index === -1) return; const keep = new Set(); for (let i = index; i <= index + PRELOAD_COUNT && i < slides.length; i++) { keep.add(slides[i]); } slides.forEach((slide) => { if (keep.has(slide)) return; if (slide.classList.contains('is-loaded')) { destroySlidePlayback(slide); } }); for (let i = index + 1; i <= index + PRELOAD_COUNT && i < slides.length; i++) { loadSlideSource(slides[i], slides[i]._videoData, false); } }; App.feed.isOpen = function() { return !!state.feedOpen; }; App.feed.renderSlides = function() { const scroller = getScroller(); if (!scroller) return; (state.loadedVideos || []).forEach((v) => { if (state.feedRenderedIds.has(v.id)) return; state.feedRenderedIds.add(v.id); const slide = document.createElement('div'); slide.className = 'feed-slide'; slide.dataset.videoId = v.id; const uploaderText = v.uploader || ''; slide.innerHTML = `

${v.title || ''}

${uploaderText ? `

${uploaderText}

` : ''}
`; const poster = slide.querySelector('.feed-poster'); App.videos.attachNoReferrerRetry(poster); slide._videoData = v; bindTimeline(slide, slide.querySelector('.feed-video')); scroller.insertBefore(slide, document.getElementById('feed-sentinel')); if (observer) observer.observe(slide); }); }; App.feed.reset = function() { const scroller = getScroller(); document.querySelectorAll('.feed-slide').forEach((slide) => { if (observer) observer.unobserve(slide); destroySlidePlayback(slide); slide.remove(); }); state.feedRenderedIds.clear(); state.feedActiveSlide = null; if (scroller) scroller.scrollTop = 0; }; App.feed.open = function() { const container = document.getElementById('feed-view'); const scroller = getScroller(); if (!container || !scroller) return; state.feedOpen = true; if (App.player && typeof App.player.close === 'function') { App.player.close(); } if (!observer) { observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { const slide = entry.target; if (entry.isIntersecting && entry.intersectionRatio >= 0.6 && state.feedActiveSlide !== slide) { const previousActive = state.feedActiveSlide; state.feedActiveSlide = slide; if (previousActive) previousActive.classList.remove('is-active'); slide.classList.add('is-active'); activateSlide(slide, slide._videoData); syncWindow(slide); } }); }, { threshold: [0, 0.6, 1] }); } if (!sentinelObserver) { sentinelObserver = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { App.videos.loadVideos(); } }, { threshold: 0.01 }); const sentinel = document.getElementById('feed-sentinel'); if (sentinel) sentinelObserver.observe(sentinel); } App.feed.renderSlides(); document.querySelectorAll('.feed-slide').forEach((slide) => observer.observe(slide)); container.classList.add('open'); container.setAttribute('aria-hidden', 'false'); document.body.classList.add('feed-mode-open'); document.body.style.overflow = 'hidden'; App.feed.updateToggleButton(); App.feed.updateMuteButton(); }; App.feed.close = function() { const container = document.getElementById('feed-view'); if (!container) return; state.feedOpen = false; document.querySelectorAll('.feed-slide').forEach((slide) => destroySlidePlayback(slide)); state.feedActiveSlide = null; container.classList.remove('open'); container.setAttribute('aria-hidden', 'true'); document.body.classList.remove('feed-mode-open'); document.body.style.overflow = 'auto'; App.feed.updateToggleButton(); }; App.feed.toggle = function() { if (state.feedOpen) { App.feed.close(); } else { App.feed.open(); } }; App.feed.toggleMute = function() { state.feedMuted = !state.feedMuted; document.querySelectorAll('.feed-video').forEach((video) => { video.muted = state.feedMuted; }); App.feed.updateMuteButton(); }; App.feed.updateToggleButton = function() { const btn = document.getElementById('mode-toggle-btn'); const icon = document.getElementById('mode-toggle-icon'); if (!btn || !icon) return; const open = !!state.feedOpen; btn.setAttribute('aria-pressed', open ? 'true' : 'false'); const label = open ? 'Back to grid' : 'Switch to Reels view'; btn.title = label; icon.alt = label; icon.src = open ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/squares-2x2.svg' : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg'; }; App.feed.updateMuteButton = function() { const icon = document.getElementById('feed-mute-icon'); if (!icon) return; icon.src = state.feedMuted ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg' : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg'; icon.alt = state.feedMuted ? 'Unmute' : 'Mute'; }; })();