diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php
index 070b450..23f0188 100644
--- a/app/Http/Controllers/VideoController.php
+++ b/app/Http/Controllers/VideoController.php
@@ -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 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()
diff --git a/resources/views/videos/index.blade.php b/resources/views/videos/index.blade.php
index b1d640f..237c561 100644
--- a/resources/views/videos/index.blade.php
+++ b/resources/views/videos/index.blade.php
@@ -211,6 +211,17 @@
@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. --}}
+
+
+
+
{{-- /filter-content --}}
@endsection
@@ -354,5 +365,69 @@
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';
+ }
+})();
@endsection
diff --git a/routes/web.php b/routes/web.php
index 708605c..31d2d89 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -24,6 +24,7 @@ Route::get('/', [VideoController::class, 'index'])->name('home');
// Video routes - public
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('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');