diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index a615b38..a6adeb0 100755
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -13,23 +13,39 @@ POLL=60 # how often to check on the app
# Upgrade yt-dlp only if PyPI has a newer version.
# Returns 0 if an update was installed, 1 otherwise (incl. errors / up to date).
+# Versions are compared numerically: the installed version can be zero-padded
+# (e.g. 2026.06.09) while PyPI reports the normalized form (e.g. 2026.6.9).
check_and_update() {
- current=$(python -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) || return 1
- latest=$(python -c "import json,urllib.request; print(json.load(urllib.request.urlopen('https://pypi.org/pypi/yt-dlp/json', timeout=30))['info']['version'])" 2>/dev/null) || {
- echo "Could not reach PyPI to check for yt-dlp updates; will retry."
- return 1
- }
+ if python3 - <<'PY'
+import json, sys, urllib.request
+try:
+ import yt_dlp
+ current = yt_dlp.version.__version__
+ latest = json.load(urllib.request.urlopen(
+ 'https://pypi.org/pypi/yt-dlp/json', timeout=30))['info']['version']
+except Exception as e:
+ print(f"Could not check for yt-dlp updates ({e}); will retry.")
+ sys.exit(1)
- if [ "$current" = "$latest" ]; then
- echo "yt-dlp is up to date ($current)."
- return 1
- fi
+def key(v):
+ try:
+ return tuple(int(p) for p in v.split('.'))
+ except ValueError:
+ return None
- echo "New yt-dlp available: $current -> $latest. Updating..."
- if pip install --upgrade --quiet --root-user-action=ignore "yt-dlp[default,curl-cffi]"; then
- return 0
+ck, lk = key(current), key(latest)
+newer = (lk > ck) if (ck and lk) else (current != latest)
+if newer:
+ print(f"New yt-dlp available: {current} -> {latest}. Updating...")
+ sys.exit(0)
+print(f"yt-dlp is up to date ({current}).")
+sys.exit(1)
+PY
+ then
+ pip install --upgrade --quiet --root-user-action=ignore "yt-dlp[default,curl-cffi]" \
+ && return 0
+ echo "yt-dlp update failed; continuing with the installed version."
fi
- echo "yt-dlp update failed; continuing with the installed version."
return 1
}
diff --git a/frontend/css/style.css b/frontend/css/style.css
index f165e55..660191e 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -1431,6 +1431,14 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
height: 1px;
}
+/* Stands in for the slides above the rendered window so scroll position and
+ snap points stay stable while slides are virtualized in and out. */
+.feed-top-spacer {
+ width: 100%;
+ height: 0;
+ flex: none;
+}
+
.feed-slide {
position: relative;
width: 100%;
diff --git a/frontend/index.html b/frontend/index.html
index c0574bb..e72a4b0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -135,6 +135,7 @@
diff --git a/frontend/js/feed.js b/frontend/js/feed.js
index 3415bd1..47f651b 100644
--- a/frontend/js/feed.js
+++ b/frontend/js/feed.js
@@ -3,10 +3,49 @@ App.feed = App.feed || {};
(function() {
const state = App.state;
- let observer = null;
- let sentinelObserver = null;
+
+ // Tuning knobs for the virtualized feed.
+ //
+ // The feed is a y-scroll-snap list where every slide is exactly one
+ // viewport tall. We never keep the whole list in the DOM. Instead we keep
+ // a sliding window of slides around the active one:
+ //
+ // [active - HISTORY_COUNT .. active + RENDER_AHEAD]
+ //
+ // Everything outside that range is removed from the document; its video
+ // JSON stays in state.loadedVideos so the slide is rebuilt instantly when
+ // the user scrolls back to (or forward into) it. A top spacer absorbs the
+ // height of the not-yet-rendered slides above the window, so adding or
+ // removing slides never shifts the scroll position: any slide at index i
+ // always sits at scrollTop === i * slideHeight regardless of the window.
+ //
+ // Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the
+ // active slide so the data buffer is always full before we need to render
+ // a slide from it.
+ const PRELOAD_COUNT = 2; // slides ahead kept with a live