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:
parent
59870862db
commit
9ad842dcd5
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
216
resources/views/videos/trending.blade.php
Normal file
216
resources/views/videos/trending.blade.php
Normal 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
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user