feat(watch): weighted Up Next + real <a> 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 <a href> 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) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:18:37 +03:00
1 parent d1fd76c278
commit 36061d1561
4 files changed
+74 -10

No files matched your search

+51 -3
View File
@@ -858,12 +858,60 @@ class VideoController extends Controller
}
}
$recommendedVideos = Video::public()
// 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)
->latest()
->limit(20)
->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',
'music' => 'videos.types.music',
+15 -7
View File
@@ -836,15 +836,22 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
{{-- Real <a href> cards so right-click / open-in-new-tab /
middle-click and screen readers all work. The SPA
transition hooks in types/*.blade.php still intercept
these clicks via data-rec-url for a soft load. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
alt="{{ $recVideo->title }}" width="168" height="94"
loading="lazy" decoding="async">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
alt="{{ $recVideo->title }}">
alt="{{ $recVideo->title }}" width="168" height="94"
loading="lazy" decoding="async">
@endif
@if ($recVideo->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
@@ -852,14 +859,15 @@
@if ($recVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
<i class="bi bi-collection-play-fill" aria-hidden="true"></i> SHORTS
</span>
@endif
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
style="color: #ef4444; margin-right: 4px; font-size: 12px;"
aria-hidden="true"></i>
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">
@@ -868,7 +876,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
</a>
@endforeach
</div>
@else
@@ -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);
@@ -307,7 +307,12 @@
border-radius: 8px;
padding: 4px;
transition: background .15s;
/* Card is now an <a href> 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);