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');