From e3eeaacc5351b2502dd51fa555c7c6a0c77fb3b4 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 24 Jun 2026 20:39:03 +0000 Subject: [PATCH] skip broken video on tintok --- frontend/js/feed.js | 92 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 638410b..46d4388 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -146,6 +146,9 @@ App.feed = App.feed || {}; video._hlsPlayer.destroy(); video._hlsPlayer = null; } + // Clearing the src below makes the element fire a spurious `error` event; + // flag the teardown so the failure handler ignores it (see markSlideFailed). + video._tearingDown = true; video.pause(); video.removeAttribute('src'); video.load(); @@ -244,12 +247,18 @@ App.feed = App.feed || {}; slide.classList.add('is-loaded'); const resolved = App.videos.resolveStreamSource(videoData); - if (!resolved.url) return; + if (!resolved.url) { + // No playable source -- treat exactly like a load failure so the + // clip is dropped from the queue and the next one takes its place. + markSlideFailed(slide); + return; + } const streamUrl = App.videos.buildStreamUrlFromSource(resolved); const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url); video.muted = state.feedMuted; video.preload = 'auto'; + video._tearingDown = false; applyResume(video, videoData && videoData.id, resolved.isLive); const startPlay = () => { @@ -267,6 +276,8 @@ App.feed = App.feed || {}; if (data && data.fatal && video._hlsPlayer === hls) { hls.destroy(); video._hlsPlayer = null; + // A fatal HLS error means the stream won't play: drop it. + markSlideFailed(slide); } }); startPlay(); @@ -337,6 +348,12 @@ App.feed = App.feed || {}; const slideVideo = slide.querySelector('.feed-video'); bindTimeline(slide, slideVideo); + // A media error (bad/expired source, network failure, unsupported codec) + // means this clip can't play -- drop it from the queue. Errors fired by + // our own teardown (src cleared) carry the _tearingDown flag and are + // ignored inside markSlideFailed. + slideVideo.addEventListener('error', () => markSlideFailed(slide)); + // On video end, either loop (handled by the `loop` flag, so `ended` // never fires) or auto-scroll to the next clip. We only advance for the // active slide so a preloaded neighbour ending early can't hijack focus. @@ -379,6 +396,79 @@ App.feed = App.feed || {}; slidesByIndex.delete(index); }; + // Re-keys every rendered slide after `removedIndex` was spliced out of + // state.loadedVideos: indices past the hole shift down by one so + // slidesByIndex (and each slide's _index) stays aligned with the queue. + const reindexAfterRemoval = function(removedIndex) { + const entries = []; + slidesByIndex.forEach((slide, i) => entries.push([i, slide])); + slidesByIndex.clear(); + entries.forEach(([i, slide]) => { + const ni = i > removedIndex ? i - 1 : i; + slide._index = ni; + slide.dataset.index = String(ni); + slidesByIndex.set(ni, slide); + }); + }; + + // Drops a video that failed to load/resolve from the queue and pulls the + // next clip into its place. A failed *preload* neighbour leaves the active + // video playing untouched; a failed *active* clip is replaced in-place by + // the next one (the broken frame is removed and the next clip slides into + // the same scroll position, so playback advances without a visible jump). + const removeVideoFromQueue = function(videoId) { + const videos = state.loadedVideos || []; + const r = videos.findIndex((v) => String(v.id) === String(videoId)); + if (r < 0) return; + const prevActive = state.feedActiveIndex; + + removeSlide(r); + videos.splice(r, 1); + reindexAfterRemoval(r); + + // Keep the grid's parallel index-based virtualization in sync with the + // now-shorter queue. + if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') { + App.virtualGrid.relayout(); + } + + if (videos.length === 0) { + App.feed.close(); + return; + } + + // The active slot only moves when the removed clip was the active one + // (r === prevActive) or, defensively, sat before it. + let newActive = prevActive; + if (r < prevActive) newActive -= 1; + newActive = clampIndex(newActive); + + state.feedActiveIndex = -1; // force setActive to re-promote the slot + setActive(newActive); + + if (r <= prevActive) { + // Active clip failed: re-anchor scroll onto the clip that slid into + // its slot so the snap container stays pinned to the new active. + const scroller = getScroller(); + if (scroller) scroller.scrollTop = newActive * slideHeight(); + } + }; + + // Flags a slide whose video failed and schedules its removal from the queue. + // Deferred to a macrotask so we never mutate slidesByIndex while setActive / + // syncWindow is mid-iteration over it. Teardown-induced errors (src cleared) + // are ignored via the video's _tearingDown flag, and we only act while the + // feed is open so late errors after close are harmless. + const markSlideFailed = function(slide) { + if (!slide || slide._failed || !state.feedOpen) return; + const video = slide.querySelector('.feed-video'); + if (video && video._tearingDown) return; + const id = slideVideoId(slide); + if (id == null) return; + slide._failed = true; + setTimeout(() => removeVideoFromQueue(id), 0); + }; + // Brings the rendered window in line with the active index: drops slides // that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds // any missing ones inside it, and sizes the top spacer to stand in for the