perf(feeds): cursor-paginated infinite scroll on /videos
Add VideoController@feedMore -> JSON { items, next_cursor, has_more }
that pages the current filter (all / shorts / match / music / lang:xx)
with Laravel's cursorPaginate(24). Cursor is stable across inserts, so
scrolling never duplicates or skips a video when something is uploaded
mid-session.
Client side: a #feed-sentinel + IntersectionObserver (rootMargin
400px) appends the next page of cards to the existing grid without a
page reload. A Load More button is always wired as the fallback when
IO is unavailable or a fetch fails. Both reuse the existing
<x-video-card/> component, so the newly fetched cards get the same
singleton edit modal + width/height thumbs as the server-rendered ones.
The initial 60-card server render stays, so the first viewport is
unchanged. The limit(60) and limit(50) caps in index() can now be
treated as "first page size" rather than a hard ceiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
73620a0f24
commit
8fdac4b483
3 files changed
+136
-1
No files matched your search
@@ -26,7 +26,66 @@ class VideoController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription']);
|
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor-paginated feed slice for infinite scroll.
|
||||||
|
*
|
||||||
|
* Called by the sentinel IntersectionObserver on /videos (and single-type
|
||||||
|
* filters). Returns rendered <x-video-card/> HTML for the next page plus
|
||||||
|
* the next cursor, or null when there's nothing left. The first page is
|
||||||
|
* served by index() as regular HTML, so this endpoint is only ever used
|
||||||
|
* once an initial grid is already on screen.
|
||||||
|
*
|
||||||
|
* Cursor is Laravel's native cursor-pagination token, which is stable
|
||||||
|
* across inserts (unlike `?page=N` which can skip or duplicate items
|
||||||
|
* when a new video is uploaded between page fetches).
|
||||||
|
*/
|
||||||
|
public function feedMore(Request $request)
|
||||||
|
{
|
||||||
|
$filter = (string) $request->query('filter', 'all');
|
||||||
|
$pageSize = 24;
|
||||||
|
|
||||||
|
$query = Video::public()->notShorts();
|
||||||
|
|
||||||
|
if (str_starts_with($filter, 'lang:')) {
|
||||||
|
$lang = strtolower(substr($filter, 5));
|
||||||
|
$primaryIds = Video::public()->where('language', $lang)->pluck('id');
|
||||||
|
$secondaryIds = VideoAudioTrack::where('language', $lang)
|
||||||
|
->whereIn('video_id', Video::public()->pluck('id'))
|
||||||
|
->pluck('video_id');
|
||||||
|
$ids = $primaryIds->concat($secondaryIds)->unique()->values();
|
||||||
|
$query = Video::public()->whereIn('id', $ids);
|
||||||
|
} elseif ($filter === 'shorts') {
|
||||||
|
$query = Video::public()->shorts();
|
||||||
|
} elseif ($filter === 'match') {
|
||||||
|
$query = Video::public()->where('type', 'match')->notShorts();
|
||||||
|
} elseif ($filter === 'music') {
|
||||||
|
$query = Video::public()->where('type', 'music')->notShorts();
|
||||||
|
} elseif ($filter === 'all' || $filter === 'latest' || $filter === '') {
|
||||||
|
// Home + Latest both show the newest-first mixed-type feed.
|
||||||
|
// Playlists intentionally stay on their own tab — this endpoint
|
||||||
|
// only pages the video grid.
|
||||||
|
$query = Video::public()->notShorts();
|
||||||
|
} else {
|
||||||
|
// Unknown filter — behave like the empty state so a crafted
|
||||||
|
// ?filter= value can never leak the whole table.
|
||||||
|
return response()->json(['items' => '', 'next_cursor' => null, 'has_more' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $query->latest('id')->cursorPaginate($pageSize);
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
foreach ($paginator->items() as $video) {
|
||||||
|
$html .= view('components.video-card', ['video' => $video])->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $html,
|
||||||
|
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||||
|
'has_more' => $paginator->hasMorePages(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
|
|||||||
@@ -211,6 +211,17 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Infinite-scroll sentinel. The IntersectionObserver down in @section('scripts')
|
||||||
|
fetches the next cursor page when this comes into view and appends the
|
||||||
|
rendered cards to the grid above. A visible "Load more" button is the
|
||||||
|
always-available fallback. --}}
|
||||||
|
<div id="feed-sentinel"
|
||||||
|
data-feed-filter="{{ $filter ?? 'all' }}"
|
||||||
|
style="height:1px;"></div>
|
||||||
|
<div id="feed-loadmore-wrap" style="display:none; text-align:center; padding:16px 0;">
|
||||||
|
<button type="button" id="feed-loadmore" class="action-btn">Load more</button>
|
||||||
|
</div>
|
||||||
</div>{{-- /filter-content --}}
|
</div>{{-- /filter-content --}}
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
@@ -354,5 +365,69 @@
|
|||||||
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
|
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* ─── Cursor-paginated infinite scroll ───────────────────────────────────
|
||||||
|
The server renders the first page of 60 cards. Everything after that is
|
||||||
|
fetched here, one 24-card page at a time, when the #feed-sentinel scrolls
|
||||||
|
into view. The sentinel sits right after the grid, so we never fetch
|
||||||
|
until the user has actually scrolled past the first page.
|
||||||
|
|
||||||
|
State lives on #feed-sentinel.dataset so a SPA filter-swap can reset it
|
||||||
|
without having to rebind listeners. Fallback: an always-visible "Load
|
||||||
|
more" button that users without IntersectionObserver can click. */
|
||||||
|
(function () {
|
||||||
|
const sentinel = document.getElementById('feed-sentinel');
|
||||||
|
const grid = document.querySelector('.yt-video-grid');
|
||||||
|
const btnWrap = document.getElementById('feed-loadmore-wrap');
|
||||||
|
const btn = document.getElementById('feed-loadmore');
|
||||||
|
if (!sentinel || !grid) return;
|
||||||
|
|
||||||
|
let cursor = ''; // null token = next request is the first continuation page
|
||||||
|
let busy = false;
|
||||||
|
let done = false;
|
||||||
|
const filter = sentinel.dataset.feedFilter || 'all';
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (busy || done) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const url = new URL("{{ route('videos.feed.more') }}", window.location.origin);
|
||||||
|
url.searchParams.set('filter', filter);
|
||||||
|
if (cursor) url.searchParams.set('cursor', cursor);
|
||||||
|
const resp = await fetch(url.toString(), {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('bad status: ' + resp.status);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.items) {
|
||||||
|
grid.insertAdjacentHTML('beforeend', data.items);
|
||||||
|
}
|
||||||
|
cursor = data.next_cursor || '';
|
||||||
|
done = !data.has_more;
|
||||||
|
} catch (e) {
|
||||||
|
// Reveal the manual Load More so a flaky network doesn't dead-end the user.
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
if (done) {
|
||||||
|
btnWrap.style.display = 'none';
|
||||||
|
sentinel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn?.addEventListener('click', loadMore);
|
||||||
|
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||||
|
}, { rootMargin: '400px 0px' });
|
||||||
|
io.observe(sentinel);
|
||||||
|
} else {
|
||||||
|
// No IntersectionObserver → surface the Load More fallback immediately.
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
@@ -24,6 +24,7 @@ Route::get('/', [VideoController::class, 'index'])->name('home');
|
|||||||
|
|
||||||
// Video routes - public
|
// Video routes - public
|
||||||
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
||||||
|
Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
|
||||||
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
|
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
|
||||||
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
||||||
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
|
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
|
||||||
|
|||||||
Reference in new issue
Block a user