Add trending videos page with YouTube-style algorithm

- Trending algorithm based on:
  - Recent views (last 48h): 70% weight
  - View velocity (views/hour): 15% weight
  - Recency bonus: 10% weight
  - Like count: 5% weight
- Excludes videos older than 10 days
- Filter options: Today/This Week/This Month
- Added route, controller method, and view
- Updated nav to link to trending page
This commit is contained in:
ghassan 2026-03-03 21:30:44 +03:00
parent 59870862db
commit 9ad842dcd5
5 changed files with 327 additions and 2 deletions

View File

@ -395,4 +395,47 @@ class VideoController extends Controller
return response()->download($path, $filename);
}
// Trending videos page
public function trending(Request $request)
{
$hours = $request->get('hours', 48); // Default: 48 hours
$limit = $request->get('limit', 50);
// Validate parameters
$hours = min(max($hours, 24), 168); // Between 24h and 7 days
$limit = min(max($limit, 10), 100);
// Get trending videos using the scope with raw score calculation
$trendingVideos = \DB::table('videos')
->select('videos.*',
\DB::raw('(
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL ' . $hours . ' HOUR)) * 0.70 +
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL ' . $hours . ' HOUR)) / ' . $hours . ' * 100 * 0.15 +
GREATEST(0, 1 - TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 +
(SELECT COUNT(*) FROM video_likes vl WHERE vl.video_id = videos.id) * 0.1 * 0.05
) as trending_score')
)
->where('visibility', 'public')
->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10))
->having('trending_score', '>', 0)
->orderByDesc('trending_score')
->limit($limit)
->get();
// Load user relationship
$trendingVideos = $trendingVideos->map(function ($video) {
$video->user = \App\Models\User::find($video->user_id);
$video->view_count = \DB::table('video_views')->where('video_id', $video->id)->count();
$video->like_count = \DB::table('video_likes')->where('video_id', $video->id)->count();
return $video;
});
return view('videos.trending', [
'videos' => $trendingVideos,
'hours' => $hours,
'limit' => $limit,
]);
}
}

View File

@ -186,3 +186,68 @@ class Video extends Model
return $this->comments()->count();
}
}
// Get recent views count (within hours)
public function getRecentViews( = 48)
{
return \DB::table('video_views')
->where('video_id', ->id)
->where('watched_at', '>=', now()->subHours())
->count();
}
// Get views in last 24 hours (for velocity calculation)
public function getViewsLast24Hours()
{
return ->getRecentViews(24);
}
// Calculate trending score (YouTube-style algorithm)
public function getTrendingScore( = 48)
{
= ->getRecentViews();
// Don't include videos older than 10 days
if (->created_at->diffInDays(now()) > 10) {
return 0;
}
// Don't include videos with no recent views
if ( < 5) {
return 0;
}
// Calculate view velocity (views per hour in last 48 hours)
= / ;
// Recency bonus: newer videos get a boost
= ->created_at->diffInHours(now());
= max(0, 1 - ( / 240)); // Decreases over 10 days
// Like count bonus
= ->like_count * 0.1;
// Calculate final score
// Weight: 70% recent views, 15% velocity, 10% recency, 5% likes
= ( * 0.70) +
( * 100 * 0.15) +
( * 50 * 0.10) +
( * 0.05);
return round(, 2);
}
// Scope for trending videos
public function scopeTrending(, = 48, = 50)
{
return ->public()
->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10))
->orderByDesc(\DB::raw('(
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL ' . . ' HOUR)) * 0.70 +
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL ' . . ' HOUR)) / ' . . ' * 100 * 0.15 +
GREATEST(0, 1 - TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 +
(SELECT COUNT(*) FROM video_likes vl WHERE vl.video_id = videos.id) * 0.1 * 0.05
)'))
->limit();
}

View File

@ -415,7 +415,7 @@
<!-- Mobile Search Overlay -->
<div class="mobile-search-overlay" id="mobileSearchOverlay">
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
<form action="{{ route('videos.trending') }}" method="GET" class="mobile-search-form">
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
<button type="submit" class="mobile-search-submit">
<i class="bi bi-search"></i>
@ -494,7 +494,7 @@
<i class="bi bi-house-door-fill"></i>
<span>Home</span>
</a>
<a href="{{ route('videos.search') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.search') ? 'active' : '' }}">
<a href="{{ route('videos.trending') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.trending') ? 'active' : '' }}">
<i class="bi bi-fire"></i>
<span>Trending</span>
</a>

View File

@ -0,0 +1,216 @@
@extends('layouts.app')
@section('title', 'Trending Videos | ' . config('app.name'))
@section('extra_styles')
<style>
.trending-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.trending-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
.trending-icon {
color: #f00;
font-size: 28px;
}
.trending-filters {
display: flex;
gap: 8px;
margin-left: auto;
}
.trending-filters a {
padding: 6px 14px;
border-radius: 18px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
background: var(--bg-secondary);
color: var(--text-secondary);
transition: all 0.2s;
}
.trending-filters a:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.trending-filters a.active {
background: var(--accent-color);
color: white;
}
.trending-badge {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
}
.trending-badge i { color: #f00; }
.yt-video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.yt-video-card { cursor: pointer; }
.yt-video-thumb {
position: relative;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
background: #1a1a1a;
}
.yt-video-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s;
}
.yt-video-card:hover .yt-video-thumb img { transform: scale(1.05); }
.yt-video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 3px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.yt-video-meta {
display: flex;
gap: 12px;
padding-top: 12px;
}
.yt-video-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.yt-video-info {
flex: 1;
min-width: 0;
}
.yt-video-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.yt-video-channel {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.yt-video-channel a {
color: var(--text-secondary);
text-decoration: none;
}
.yt-video-stats { font-size: 13px; color: var(--text-secondary); }
@media (max-width: 1200px) {
.yt-video-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.trending-header { flex-wrap: wrap; }
.trending-filters { margin-left: 0; width: 100%; margin-top: 12px; }
.yt-video-grid { grid-template-columns: 1fr; gap: 20px; }
}
.empty-trending {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-trending i { font-size: 64px; margin-bottom: 16px; opacity: 0.5; }
.empty-trending h3 { font-size: 20px; color: var(--text-primary); margin-bottom: 8px; }
</style>
@endsection
@section('content')
<div class="trending-header">
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
<div class="trending-filters">
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ == 24 ? 'active' : '' }}">Today</a>
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ == 48 ? 'active' : '' }}">This Week</a>
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ == 168 ? 'active' : '' }}">This Month</a>
</div>
</div>
@if(())
<div class="empty-trending">
<i class="bi bi-play-circle"></i>
<h3>No trending videos yet</h3>
<p>Videos with high engagement will appear here</p>
</div>
@else
<div class="yt-video-grid">
@foreach( as )
<a href="{{ route('videos.show', ) }}" class="yt-video-card">
<div class="yt-video-thumb">
<img src="{{ }}" alt="{{ }}" loading="lazy">
<span class="trending-badge"><i class="bi bi-fire"></i> Trending</span>
@if()<span class="yt-video-duration">{{ gmdate('i:s', ) }}</span>@endif
</div>
<div class="yt-video-meta">
@if()<img src="{{ ->avatar_url }}" alt="{{ ->name }}" class="yt-video-avatar">@endif
<div class="yt-video-info">
<h3 class="yt-video-title">{{ }}</h3>
<div class="yt-video-channel"><a href="#">{{ ->name ?? 'Unknown' }}</a></div>
<div class="yt-video-stats">
{{ number_format() }} views
@if( > 0) {{ number_format() }} likes @endif
</div>
</div>
</div>
</a>
@endforeach
</div>
@endif
@endsection

View File

@ -14,6 +14,7 @@ Route::get('/', function () {
// Video routes - public
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');