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:
ghassanandClaude Opus 4.7 committed 2026-10-21 00:50:59 +03:00
1 parent 73620a0f24
commit 8fdac4b483
3 files changed
+136 -1

No files matched your search

+60 -1
View File
@@ -26,7 +26,66 @@ class VideoController extends Controller
{
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()