feat(shorts): single reused <video> element + 1-2 next preloaded
The Shorts feed used to emit one <video id="sv-{idx}"> per short. With
60 shorts, that was 60 HTMLMediaElement allocations even though only
one plays at a time — bandwidth was bounded by preload="none" but the
DOM shape was still N:1.
Replace with the YT-parity architecture:
- One #svGlobal <video> rendered once at the end of the feed. The
IntersectionObserver docks it into the active .short-player-wrap on
entry and sets its src from the slot's data-src. Browsers preserve
the active media context across node moves so playback doesn't
restart just because we re-parented the node.
- Per-slot .short-poster shim keeps the slot filled with the video's
thumbnail while the shared player is docked elsewhere; the slot
never goes blank between swaps.
- Two #svPre1 / #svPre2 preloaders park at the end of the DOM and get
their src set to the NEXT one and two shorts on each dock. preload=
"metadata" grabs the moov atom + first keyframe only — enough that
the switch to playback is instant, cheap enough that we can run two.
- The <video>'s single timeupdate listener now updates the
.short-progress-fill of whichever slot it's currently docked in,
found via video.parentElement.id.replace('wrap-', '').
- toggleMute now mutes the shared video directly instead of looking
up the no-longer-existent sv-{idx} by id.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
c59b36ebf7
commit
e76ac39f99
2 files changed
+193
-38
No files matched your search
@@ -64,6 +64,20 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Poster shim shown behind the <video> (and visible on inactive slots).
|
||||
Keeps the slot filled with content when the shared player node has
|
||||
moved to a different slot. */
|
||||
.short-poster {
|
||||
position: absolute; inset: 0;
|
||||
background-size: cover; background-position: center;
|
||||
background-color: #000;
|
||||
}
|
||||
/* Hidden preloader elements that fetch metadata for the next 1-2 shorts
|
||||
without rendering anything visible. preload="metadata" is the
|
||||
smallest-possible warmup; the browser pulls the moov atom + first
|
||||
keyframe, enough that the switch to playback is instant. */
|
||||
.short-preloader { display: none; }
|
||||
|
||||
/* ── Tap-to-pause icon ── */
|
||||
.short-pause-icon {
|
||||
position: absolute;
|
||||
@@ -337,18 +351,21 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Video player --}}
|
||||
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
|
||||
{{-- Video player wrap. The actual <video> element is a SINGLE shared
|
||||
node (defined once at the bottom of the feed) that gets moved
|
||||
between slots as the active short changes. Each slot carries its
|
||||
own src + poster on data- attributes and the slot's inner div
|
||||
shows the poster until the player docks here. --}}
|
||||
<div class="short-player-wrap" id="wrap-{{ $index }}"
|
||||
data-src="{{ $src }}"
|
||||
data-poster="{{ $thumb }}"
|
||||
onclick="toggleShortPlay({{ $index }})">
|
||||
|
||||
<video
|
||||
id="sv-{{ $index }}"
|
||||
src="{{ $src }}"
|
||||
poster="{{ $thumb }}"
|
||||
loop
|
||||
playsinline
|
||||
preload="{{ $loop->first ? 'auto' : 'none' }}"
|
||||
style="width:100%;height:100%;object-fit:cover;">
|
||||
</video>
|
||||
{{-- Poster shim: covers the slot before the shared <video> moves in.
|
||||
Also stays visible on inactive slots so the viewport doesn't
|
||||
go blank when the user is between shorts. --}}
|
||||
<div class="short-poster"
|
||||
@if($thumb) style="background-image:url('{{ $thumb }}');" @endif></div>
|
||||
|
||||
{{-- Pause icon --}}
|
||||
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
|
||||
@@ -429,6 +446,21 @@
|
||||
|
||||
@endforelse
|
||||
|
||||
{{-- Single shared <video> moved between slots as the active short changes.
|
||||
Starts out detached (parked here in the DOM); the IntersectionObserver
|
||||
relocates it into .short-player-wrap of the active slot. --}}
|
||||
<video id="svGlobal"
|
||||
loop playsinline
|
||||
preload="metadata"
|
||||
style="width:100%;height:100%;object-fit:cover;display:block;position:absolute;inset:0;">
|
||||
</video>
|
||||
|
||||
{{-- Preloaders for the next 1-2 shorts. Hidden; preload="metadata" is
|
||||
enough that switching to playback is instant. We assign srcs
|
||||
dynamically — defining them here keeps the DOM shape stable. --}}
|
||||
<video id="svPre1" class="short-preloader" preload="metadata" muted></video>
|
||||
<video id="svPre2" class="short-preloader" preload="metadata" muted></video>
|
||||
|
||||
{{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
|
||||
Populated via fetch of the regular watch page's #ytcSection block so
|
||||
all the comment features (likes, replies, translate, heart/pin) work
|
||||
@@ -496,13 +528,21 @@
|
||||
@section('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
const main = document.getElementById('main');
|
||||
const main = document.getElementById('main');
|
||||
const video = document.getElementById('svGlobal');
|
||||
const pre1 = document.getElementById('svPre1');
|
||||
const pre2 = document.getElementById('svPre2');
|
||||
// Persist the mute state across sessions. Default to muted so autoplay
|
||||
// policies don't silently fail on first load; the user can unmute once
|
||||
// and we remember it from then on.
|
||||
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
|
||||
let current = -1;
|
||||
|
||||
// Build an ordered list of slot wraps so neighbor lookup is a plain
|
||||
// array index — we need to know "the slot at position N+1" to know
|
||||
// which short to preload next.
|
||||
const wraps = Array.from(document.querySelectorAll('.short-player-wrap'));
|
||||
|
||||
// ── Deep-link to /shorts/{id} — scroll the matching item into view
|
||||
// before the observer starts, so autoplay fires on the target short
|
||||
// rather than the first in document order. ────────────────────
|
||||
@@ -513,27 +553,62 @@
|
||||
})();
|
||||
@endif
|
||||
|
||||
function _dockVideoIn(wrap) {
|
||||
// Move the shared <video> element into the active wrap. The browser
|
||||
// keeps the active media context across the move, so playback
|
||||
// doesn't restart just because we re-parented the node.
|
||||
if (video.parentElement !== wrap) {
|
||||
// Append after the poster so the poster acts as background
|
||||
// while the video decodes its first frame.
|
||||
wrap.appendChild(video);
|
||||
}
|
||||
}
|
||||
|
||||
function _primePreloaders(idx) {
|
||||
// Preload metadata for the next 1-2 shorts so switching feels
|
||||
// instant. preload="metadata" grabs just the moov atom + first
|
||||
// keyframe; the browser doesn't fetch the whole file.
|
||||
[pre1, pre2].forEach((pre, i) => {
|
||||
const target = wraps[idx + 1 + i];
|
||||
const nextSrc = target?.dataset.src || '';
|
||||
if (pre.src !== nextSrc) pre.src = nextSrc;
|
||||
});
|
||||
}
|
||||
|
||||
function pauseShort(idx) {
|
||||
const wrap = document.getElementById('wrap-' + idx);
|
||||
if (video && video.parentElement === wrap) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}
|
||||
if (wrap) wrap.classList.remove('paused');
|
||||
const prog = document.getElementById('prog-' + idx);
|
||||
if (prog) prog.style.width = '0%';
|
||||
}
|
||||
|
||||
// ── IntersectionObserver — auto-play visible short ─────────
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
const item = entry.target;
|
||||
const idx = parseInt(item.dataset.index);
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
if (!video) return;
|
||||
const wrap = document.getElementById('wrap-' + idx);
|
||||
if (!wrap) return;
|
||||
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
|
||||
if (current !== -1 && current !== idx) pauseShort(current);
|
||||
current = idx;
|
||||
|
||||
// ── Reflect the active short in the URL (no history entry) ──
|
||||
// Users can copy-paste the address bar and get back here; the
|
||||
// back button still exits the feed instead of walking through
|
||||
// every short they viewed.
|
||||
try {
|
||||
const vid = item.dataset.id;
|
||||
if (vid) history.replaceState(null, '', '/shorts/' + vid);
|
||||
} catch (_) {}
|
||||
|
||||
_dockVideoIn(wrap);
|
||||
const nextSrc = wrap.dataset.src || '';
|
||||
if (video.src !== nextSrc) video.src = nextSrc;
|
||||
video.poster = wrap.dataset.poster || '';
|
||||
|
||||
video.muted = muted;
|
||||
video.volume = 1;
|
||||
video.play().catch(() => {
|
||||
@@ -544,7 +619,9 @@
|
||||
updateMuteIcon(idx);
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
document.getElementById('wrap-' + idx).classList.remove('paused');
|
||||
wrap.classList.remove('paused');
|
||||
|
||||
_primePreloaders(idx);
|
||||
} else {
|
||||
pauseShort(idx);
|
||||
}
|
||||
@@ -555,9 +632,8 @@
|
||||
|
||||
// ── Play / pause on tap ────────────────────────────────────
|
||||
window.toggleShortPlay = function(idx) {
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
const wrap = document.getElementById('wrap-' + idx);
|
||||
if (!video) return;
|
||||
if (!wrap || video.parentElement !== wrap) return;
|
||||
if (video.paused) {
|
||||
video.play().catch(() => {});
|
||||
wrap.classList.remove('paused');
|
||||
@@ -567,31 +643,23 @@
|
||||
}
|
||||
};
|
||||
|
||||
function pauseShort(idx) {
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
const wrap = document.getElementById('wrap-' + idx);
|
||||
if (video) { video.pause(); video.currentTime = 0; }
|
||||
if (wrap) wrap.classList.remove('paused');
|
||||
// ── Progress bar — a single listener on the shared video updates the
|
||||
// bar in whichever slot it's currently docked. --
|
||||
video.addEventListener('timeupdate', () => {
|
||||
if (!video.duration) return;
|
||||
const wrap = video.parentElement;
|
||||
if (!wrap || !wrap.classList.contains('short-player-wrap')) return;
|
||||
const idx = wrap.id.replace('wrap-', '');
|
||||
const prog = document.getElementById('prog-' + idx);
|
||||
if (prog) prog.style.width = '0%';
|
||||
}
|
||||
|
||||
// ── Progress bars ──────────────────────────────────────────
|
||||
document.querySelectorAll('.short-item video').forEach(video => {
|
||||
const idx = video.id.replace('sv-', '');
|
||||
video.addEventListener('timeupdate', () => {
|
||||
if (!video.duration) return;
|
||||
const prog = document.getElementById('prog-' + idx);
|
||||
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
||||
});
|
||||
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
|
||||
});
|
||||
|
||||
// ── Mute toggle ────────────────────────────────────────────
|
||||
window.toggleMute = function(idx) {
|
||||
muted = !muted;
|
||||
localStorage.setItem('shortsMuted', muted ? '1' : '0');
|
||||
const video = document.getElementById('sv-' + idx);
|
||||
if (video) video.muted = muted;
|
||||
// The shared video is the only one playing; mute it directly.
|
||||
video.muted = muted;
|
||||
updateMuteIcon(idx);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user