From 36061d1561556d829af92831e6f4c414d2bba676 Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 10:18:37 +0300 Subject: [PATCH] feat(watch): weighted Up Next + real cards - VideoController@show now scores the newest 200 public ready videos against the one being watched: +4 same type, +3 same language, +2 any tag overlap, +1 same channel. Sort by score desc then by newest. Replaces the plain "latest 20 excluding self" list that was often dominated by whatever happened to be uploaded last, with no regard to what the viewer actually chose. - Sidebar cards are real elements again, so middle-click, right-click, cmd-click and screen readers all work. The SPA click interceptors in types/{music,generic}.blade.php still fire via data-rec-url for a soft-swap when the user left-clicks normally. - loading="lazy" + width/height on the sidebar thumbnails so a long Up Next list doesn't blow the critical path. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/VideoController.php | 58 +++++++++++++++++-- resources/views/videos/show.blade.php | 24 +++++--- .../views/videos/types/generic.blade.php | 3 + resources/views/videos/types/music.blade.php | 5 ++ 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 2f63a02..9498dbd 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -858,11 +858,59 @@ class VideoController extends Controller } } - $recommendedVideos = Video::public() - ->where('id', '!=', $video->id) - ->latest() - ->limit(20) - ->get(); + // Up Next recommendations: a weighted blend instead of the plain + // chronological "newest 20" the sidebar used to show. + // + // +4 same type (music / match / generic) + // +3 same primary language + // +2 overlapping tag (any match in the comma-separated tags list) + // +1 same channel (keeps a creator's recent uploads surfaced, but + // does not dominate — cross-channel results compete on type/ + // language/tags scoring) + // + // Candidates: the newest ~200 public ready videos excluding the one + // being watched. 200 is big enough for the blend to find real + // similarities, small enough to score in-process without a query + // per candidate. + $recommendedVideos = (function () use ($video) { + $candidates = Video::public() + ->where('status', 'ready') + ->where('id', '!=', $video->id) + ->with('user') + ->latest('id') + ->limit(200) + ->get(); + + $ownTags = collect(explode(',', (string) ($video->tags ?? ''))) + ->map(fn ($t) => mb_strtolower(trim($t))) + ->filter() + ->unique(); + + $scored = $candidates->map(function (Video $cand) use ($video, $ownTags) { + $score = 0; + if ($cand->type === $video->type) $score += 4; + if ($video->language && $cand->language === $video->language) $score += 3; + if ($ownTags->isNotEmpty()) { + $candTags = collect(explode(',', (string) ($cand->tags ?? ''))) + ->map(fn ($t) => mb_strtolower(trim($t))) + ->filter(); + if ($ownTags->intersect($candTags)->isNotEmpty()) $score += 2; + } + if ($cand->user_id === $video->user_id) $score += 1; + + $cand->_rec_score = $score; + return $cand; + }); + + // Sort by score desc, then newest. Keep up to 20. + return $scored + ->sortBy([ + ['_rec_score', 'desc'], + ['created_at', 'desc'], + ]) + ->take(20) + ->values(); + })(); $view = match ($video->type) { 'match' => 'videos.types.match', diff --git a/resources/views/videos/show.blade.php b/resources/views/videos/show.blade.php index 395e405..10f65b7 100644 --- a/resources/views/videos/show.blade.php +++ b/resources/views/videos/show.blade.php @@ -836,30 +836,38 @@ @if ($recommendedVideos && $recommendedVideos->count() > 0) + @endforeach @else diff --git a/resources/views/videos/types/generic.blade.php b/resources/views/videos/types/generic.blade.php index 643095b..36e4206 100644 --- a/resources/views/videos/types/generic.blade.php +++ b/resources/views/videos/types/generic.blade.php @@ -307,7 +307,10 @@ border-radius: 8px; padding: 4px; transition: background .15s; + color: inherit; + text-decoration: none; } + .sidebar-video-card:hover { background: rgba(255,255,255,.05); } .sidebar-video-card.current-video { background: rgba(239,68,68,.12); diff --git a/resources/views/videos/types/music.blade.php b/resources/views/videos/types/music.blade.php index 524a51a..120380f 100644 --- a/resources/views/videos/types/music.blade.php +++ b/resources/views/videos/types/music.blade.php @@ -307,7 +307,12 @@ border-radius: 8px; padding: 4px; transition: background .15s; + /* Card is now an for real right-click / open-in-new-tab + / middle-click support. Strip the default link styling. */ + color: inherit; + text-decoration: none; } + .sidebar-video-card:hover { background: rgba(255,255,255,.05); } .sidebar-video-card.current-video { background: rgba(239,68,68,.12);