perf(web-ui): cut player re-render cost when EPG arrives - #733
Merged
Conversation
Render each day of the programme guide only once it approaches the scroll viewport (shared IntersectionObserver, fixed-height placeholders), always rendering the section holding the playing programme so auto-scroll still works. Row time labels are formatted once per EPG update with a cached Intl.DateTimeFormat instead of toLocaleTimeString per render, and the on-air clock ticks on minute boundaries instead of every second. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
All VideoPlayer props are primitives or stable callbacks and the 1 Hz playback clock flows through PlaybackTimeContext, so memo() lets it skip re-rendering on unrelated PlayerPage updates. The EPG channel id for the current channel is now computed once and shared by the programme lookup and the EPG view. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
fillEPGGaps used to run twice on the main thread (an empty-EPG placeholder at startup and again on the full EPG when the worker finished, the latter costing 200-300 ms for catchup playlists). The worker now receives channel descriptors, derives the valid id set, parses and gap-fills, and posts the final data; the main thread shows no EPG until it arrives. When there is no x-tvg-url or the fetch fails the worker still returns fallback-only data so catchup channels stay seekable. generateFallbackPrograms replaces the per-slot .some() scan with two cursors over the start-sorted list and a linear merge, taking the all-catchup case from ~300 ms to ~11 ms. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
Contributor
Documentation previewThe documentation preview has been deployed for this pull request. |
PlayerPage used to setState (plus a deferred re-render) every second from the player's time updates, re-rendering the whole page and everything not memoized beneath it, and interrupting in-flight transitions such as the EPG arrival render. The clock now lives in a small external store behind PlaybackTimeProvider, consumed via useSyncExternalStore only by the timeline and time readout. The current programme is still derived in render, but its time input is only bumped when the clock actually crosses a programme boundary (or the lookup inputs change), so the page re-renders on programme changes, channel switches, seeks and EPG arrival instead of every second. Seek/reset paths still zero the clock as before. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
…hydrated Observing against the scroll container depended on when its ref was attached relative to the observer effect; observe the viewport instead (the list fills the sidebar height, so the result is identical). Also eagerly render the sections adjacent to the playing programme so the auto-scrolled viewport is full on reveal, and latch hydration so a section never falls back to a placeholder once it has been rendered. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
react-dom 19.2 refreshes useEffectEvent implementations in the before-mutation phase only for FunctionComponent fibers; the SimpleMemoComponent fiber created by memo() is skipped, so every Effect Event in VideoPlayer kept its mount-time props. In practice playMode stayed "live" and streamStartTime stayed at page load, so the first "playing" event of a catchup stream recalibrated the live session and rebuilt the catchup URL at "now" in a loop. Revert to the plain export; the 1 Hz page re-render memo() was meant to absorb is gone since the playback clock moved to an external store. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
stackia
marked this pull request as ready for review
September 2, 2026 04:51
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 48cc2ce. Configure here.
The timer that switches the guide's auto-scroll to smooth ran before the "no programmes yet" early return, so when the guide was open before the EPG arrived the very first positioning animated. Smooth scrolling is meant only for programme changes while the guide is already showing, so arm it only after a scroll target has actually existed. Tab switches already request instant via nextScrollBehaviorRef. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
…f lazy sections Drop the hand-maintained section hydration (IntersectionObserver, fixed-height placeholders, eager set, latch). Every day renders in full again; instead the list is derived from useDeferredValue(channelId / epgData / currentPlayingProgram) so its work always runs in a non-urgent, interruptible lane: EPG arrival is already a transition and passes straight through, while urgent updates such as a channel zap or a programme boundary commit the cheap parts first and let React render the rows in the background, yielding to user input between them. Precomputed row labels, the minute-aligned clock and the instant first positioning are kept. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
…idgets EPGView, ChannelList and the top-left overlay clock each ran their own 60 s timer (ChannelList's not even minute-aligned), so their notion of "now" could disagree by up to a minute and ChannelList showed a stale value after its tab had been hidden. Replace them with a single module-level, minute-aligned ticker exposed through useSyncExternalStore (useWallClockMinute). It starts with the first subscriber, refreshes the value immediately on subscribe (tab reveal), re-aligns on visibilitychange and stops when nobody listens. The media clock (video position) stays a separate, event-driven source: it can be paused, stalled or point into the past during catchup, so it cannot stand in for real time. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
PlaybackTimeStore was a dumb value store written from a PlayerPage callback; the whole-second throttling, the two position refs and the programme-boundary detection lived in PlayerPage around it. Replace it with PlaybackClock, which owns the media-clock semantics (raw position, 1 Hz snapshot, reset) and is fed directly by VideoPlayer from the active backend's time-update; VideoPlayer also publishes it to its own controls through PlaybackTimeProvider, so the onCurrentVideoTimeChange prop and the provider in PlayerPage go away. The programme-follows-the-clock logic moves into useCurrentVideoProgram, leaving PlayerPage with a single hook call. Seek/reset paths still zero the clock via resetCurrentVideoTime. Co-authored-by: Stackie Jia <jsq2627@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Reduces main-thread work in the web player at the moment the EPG worker posts its result, keeps the guide render interruptible, removes the per-second whole-page re-render, and consolidates the page's clocks.
Changes
EPGViewrenders from deferred values — the programme list (hundreds ofmemo'd rows) is derived fromuseDeferredValue(channelId / epgData / currentPlayingProgram), so its work always runs in a non-urgent, interruptible lane: EPG arrival is already astartTransitionand passes straight through; urgent updates (channel zap, programme boundary) commit the cheap parts first and React renders the rows in the background, yielding to user input between rows. Row time labels are precomputed once per EPG update with a cachedIntl.DateTimeFormat. (An earlier revision of this branch used hand-maintained lazy day sections with anIntersectionObserver; that was dropped in favour of this simpler model.)PlaybackClock(components/player/playback-clock.ts) owns the playback position semantics: raw position, a snapshot that changes at whole seconds,reset().VideoPlayerfeeds it from the active backend'stime-updateand publishes it to its own controls viaPlaybackTimeProvider(useSyncExternalStore);useCurrentVideoProgrambinds the current programme to it, bumping its lookup time only on programme boundaries (with a self-correcting effect forstreamStartTimerecalibration and reset).PlayerPageno longer holds any per-second state, refs or callbacks.EPGView,ChannelListand the top-left overlay clock each ran their own 60 s timer (ChannelList's not minute-aligned, and stale after its tab was hidden). They now subscribe to one module-level, minute-aligned ticker (useWallClockMinute) that starts with the first subscriber, refreshes on subscribe, re-aligns onvisibilitychangeand stops when unused. The media clock cannot stand in for it: it can be paused, stalled or point into the past during catchup.fillEPGGapsused to run twice on the main thread (startup placeholder + again on arrival, 200–300 ms for catchup playlists). The worker now receives channel descriptors, parses, gap-fills and posts final data; the UI shows no EPG until it arrives. Withoutx-tvg-url(or on fetch failure) the worker still returns fallback-only data so catchup channels stay seekable.generateFallbackProgramsreplaces the per-slot.some()scan with two cursors over the start-sorted list + a linear merge: ~300 ms → ~11 ms on the 海南 EPG with all channels catchup-enabled (山西 output byte-identical to before; 海南 differs only for one channel with 9 malformed programmes whosestopprecedesstart, where the old scan accidentally suppressed a day of gap-fill).PlayerPagecomputes the EPG channel id once and shares it.VideoPlayeris deliberately notmemo()-wrapped: react-dom 19.2 only refreshesuseEffectEventimplementations forFunctionComponentfibers, not theSimpleMemoComponentfibermemo()creates. An earlier commit on this branch addedmemoand it froze every Effect Event at mount-time props (playModestayed"live",streamStartTimeat page load), so the firstplayingevent of a catchup stream recalibrated the live session and rebuilt the catchup URL at "now" in a loop. Reverted with an explanatory comment.Measurements (taken on the lazy-section revision; the last three revisions have not been re-profiled)
Headless Chrome, 4× CPU throttle, median of 3, window = EPG arrival −100 ms … +1500 ms; "busy" = sum of renderer main-thread
RunTaskdurations (details inepg_arrival_profile_results.mdartifact):Not captured by headless: the removed 2×/s
PlayerPage+VideoPlayerre-render during playback, and the 200–300 ms → ~11 ms gap-fill for catchup playlists.Verification
tsc --noEmit,biome check,vite buildpass on the final revision. Earlier revisions:./scripts/run-e2e.sh test_pages.py test_epg.py(12 passed); scripted CDP runs against devlab (catchup from a guide row starts at 0:00, Go Live returns, keyboard zapping identical tomain); manual GUI walkthrough. The last three revisions are to be verified manually by the author.