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:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:54:08 +03:00
1 parent c59b36ebf7
commit e76ac39f99
2 files changed
+193 -38

No files matched your search

+106 -38
View File
@@ -64,6 +64,20 @@
display: block; 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 ── */ /* ── Tap-to-pause icon ── */
.short-pause-icon { .short-pause-icon {
position: absolute; position: absolute;
@@ -337,18 +351,21 @@
@endif @endif
</div> </div>
{{-- Video player --}} {{-- Video player wrap. The actual <video> element is a SINGLE shared
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})"> 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 {{-- Poster shim: covers the slot before the shared <video> moves in.
id="sv-{{ $index }}" Also stays visible on inactive slots so the viewport doesn't
src="{{ $src }}" go blank when the user is between shorts. --}}
poster="{{ $thumb }}" <div class="short-poster"
loop @if($thumb) style="background-image:url('{{ $thumb }}');" @endif></div>
playsinline
preload="{{ $loop->first ? 'auto' : 'none' }}"
style="width:100%;height:100%;object-fit:cover;">
</video>
{{-- Pause icon --}} {{-- Pause icon --}}
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div> <div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
@@ -429,6 +446,21 @@
@endforelse @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. {{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
Populated via fetch of the regular watch page's #ytcSection block so Populated via fetch of the regular watch page's #ytcSection block so
all the comment features (likes, replies, translate, heart/pin) work all the comment features (likes, replies, translate, heart/pin) work
@@ -496,13 +528,21 @@
@section('scripts') @section('scripts')
<script> <script>
(function () { (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 // Persist the mute state across sessions. Default to muted so autoplay
// policies don't silently fail on first load; the user can unmute once // policies don't silently fail on first load; the user can unmute once
// and we remember it from then on. // and we remember it from then on.
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1'; let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
let current = -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 // ── Deep-link to /shorts/{id} — scroll the matching item into view
// before the observer starts, so autoplay fires on the target short // before the observer starts, so autoplay fires on the target short
// rather than the first in document order. ──────────────────── // rather than the first in document order. ────────────────────
@@ -513,27 +553,62 @@
})(); })();
@endif @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 ───────── // ── IntersectionObserver — auto-play visible short ─────────
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
entries.forEach(entry => { entries.forEach(entry => {
const item = entry.target; const item = entry.target;
const idx = parseInt(item.dataset.index); const idx = parseInt(item.dataset.index);
const video = document.getElementById('sv-' + idx); const wrap = document.getElementById('wrap-' + idx);
if (!video) return; if (!wrap) return;
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) { if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
if (current !== -1 && current !== idx) pauseShort(current); if (current !== -1 && current !== idx) pauseShort(current);
current = idx; current = idx;
// ── Reflect the active short in the URL (no history entry) ── // ── 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 { try {
const vid = item.dataset.id; const vid = item.dataset.id;
if (vid) history.replaceState(null, '', '/shorts/' + vid); if (vid) history.replaceState(null, '', '/shorts/' + vid);
} catch (_) {} } catch (_) {}
_dockVideoIn(wrap);
const nextSrc = wrap.dataset.src || '';
if (video.src !== nextSrc) video.src = nextSrc;
video.poster = wrap.dataset.poster || '';
video.muted = muted; video.muted = muted;
video.volume = 1; video.volume = 1;
video.play().catch(() => { video.play().catch(() => {
@@ -544,7 +619,9 @@
updateMuteIcon(idx); updateMuteIcon(idx);
video.play().catch(() => {}); video.play().catch(() => {});
}); });
document.getElementById('wrap-' + idx).classList.remove('paused'); wrap.classList.remove('paused');
_primePreloaders(idx);
} else { } else {
pauseShort(idx); pauseShort(idx);
} }
@@ -555,9 +632,8 @@
// ── Play / pause on tap ──────────────────────────────────── // ── Play / pause on tap ────────────────────────────────────
window.toggleShortPlay = function(idx) { window.toggleShortPlay = function(idx) {
const video = document.getElementById('sv-' + idx);
const wrap = document.getElementById('wrap-' + idx); const wrap = document.getElementById('wrap-' + idx);
if (!video) return; if (!wrap || video.parentElement !== wrap) return;
if (video.paused) { if (video.paused) {
video.play().catch(() => {}); video.play().catch(() => {});
wrap.classList.remove('paused'); wrap.classList.remove('paused');
@@ -567,31 +643,23 @@
} }
}; };
function pauseShort(idx) { // ── Progress bar — a single listener on the shared video updates the
const video = document.getElementById('sv-' + idx); // bar in whichever slot it's currently docked. --
const wrap = document.getElementById('wrap-' + idx); video.addEventListener('timeupdate', () => {
if (video) { video.pause(); video.currentTime = 0; } if (!video.duration) return;
if (wrap) wrap.classList.remove('paused'); 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); const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = '0%'; if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
}
// ── 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) + '%';
});
}); });
// ── Mute toggle ──────────────────────────────────────────── // ── Mute toggle ────────────────────────────────────────────
window.toggleMute = function(idx) { window.toggleMute = function(idx) {
muted = !muted; muted = !muted;
localStorage.setItem('shortsMuted', muted ? '1' : '0'); localStorage.setItem('shortsMuted', muted ? '1' : '0');
const video = document.getElementById('sv-' + idx); // The shared video is the only one playing; mute it directly.
if (video) video.muted = muted; video.muted = muted;
updateMuteIcon(idx); updateMuteIcon(idx);
}; };
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Shorts: single reused <video> element + 1-2 next preloaded.
*
* Validates that the shorts feed markup matches the architecture:
* - a single #svGlobal <video> rendered once at the end of the forelse,
* - two preloader placeholders (#svPre1 / #svPre2),
* - no per-short <video> elements inside .short-player-wrap,
* - each slot carries its own data-src + data-poster so the shared player
* can swap into position without a full DOM rebuild.
*/
class ShortsReusedVideoTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
public function test_shorts_feed_renders_a_single_shared_video_element(): void
{
$u = User::factory()->create([
'username' => 'sh-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
// Three shorts so we can prove only ONE <video id="svGlobal"> is
// emitted no matter how many shorts there are.
for ($i = 0; $i < 3; $i++) {
Video::create([
'user_id' => $u->id,
'title' => 'Short '.$i,
'description' => '',
'filename' => "users/x/videos/y{$i}/video.mp4",
'path' => "users/x/videos/y{$i}/video.mp4",
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => true,
'duration' => 45,
'orientation' => 'portrait',
'share_token' => bin2hex(random_bytes(8)),
]);
}
$html = $this->get(route('videos.shorts'))->assertOk()->getContent();
// Exactly one shared video element and two preloaders.
$this->assertEquals(
1,
substr_count($html, 'id="svGlobal"'),
'Shorts feed must emit exactly one shared <video id="svGlobal">.'
);
$this->assertStringContainsString('id="svPre1"', $html);
$this->assertStringContainsString('id="svPre2"', $html);
// No per-short video elements inside the slot wraps.
$this->assertDoesNotMatchRegularExpression(
'/short-player-wrap[^>]*>[^<]*<video id="sv-\d+/',
$html,
'Per-short <video id="sv-{idx}"> elements should be gone.'
);
// Each slot still carries its own src + poster for the shared
// player to pick up on dock.
$this->assertStringContainsString('data-src=', $html);
$this->assertStringContainsString('data-poster=', $html);
}
}