Language filter chips on the videos feed

Adds one chip per language currently present in public content —
counting both primary language and any secondary audio track, sorted
by video count, capped at 12. Clicking a chip lists every video whose
primary is that language or that has a matching secondary track; cards
surfaced only via a secondary track link straight to /videos/{key}?track=<id>
so the pinned playback wiring loads the right audio, get a small flag
badge on the thumbnail, and their hover-preview streams the track file
instead of the primary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-07-31 03:27:42 +03:00
parent 8d27520bdb
commit fcb752dd76
4 changed files with 163 additions and 10 deletions

View File

@ -33,6 +33,46 @@ class VideoController extends Controller
{ {
$filter = request('filter', 'all'); $filter = request('filter', 'all');
// Chips: distinct languages that actually appear in public content —
// either as a video's primary language or as any of its audio tracks.
// Sorted by video count so the most-used languages come first, capped
// at 12 so the bar doesn't overflow.
$langChips = $this->buildLanguageChips();
// ── Filter by language (?filter=lang:xx) ──────────────────
// Matches when the video's primary language is xx OR when any of
// its secondary audio tracks is xx. When a card is being shown only
// because of a secondary track, we tag it so video-card can link
// straight to that track via ?track=ID and badge it with the flag.
if (str_starts_with((string) $filter, 'lang:')) {
$lang = strtolower(substr($filter, 5));
$primaryIds = \App\Models\Video::public()->where('language', $lang)->pluck('id');
$secondaryTracks = \App\Models\VideoAudioTrack::where('language', $lang)
->whereIn('video_id', \App\Models\Video::public()->pluck('id'))
->get();
$secondaryVideoIds = $secondaryTracks->pluck('video_id')->unique();
// If the video is already primary-matched, no track override.
// Otherwise pin the surfaced track.
$trackByVideo = $secondaryTracks
->filter(fn ($t) => ! $primaryIds->contains($t->video_id))
->keyBy('video_id');
$ids = $primaryIds->concat($secondaryVideoIds)->unique()->values();
$videos = \App\Models\Video::public()->whereIn('id', $ids)
->latest()->limit(60)->get();
foreach ($videos as $v) {
if ($trackByVideo->has($v->id)) {
$v->setAttribute('_force_track_id', $trackByVideo[$v->id]->id);
$v->setAttribute('_force_track_flag', \App\Data\Languages::flag($lang));
}
}
return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]);
}
// ── Playlists-only browse ────────────────────────────────── // ── Playlists-only browse ──────────────────────────────────
if ($filter === 'playlists') { if ($filter === 'playlists') {
$playlists = Playlist::where('visibility', 'public') $playlists = Playlist::where('visibility', 'public')
@ -41,7 +81,7 @@ class VideoController extends Controller
->latest() ->latest()
->limit(60) ->limit(60)
->get(); ->get();
return view('videos.index', compact('playlists', 'filter') + [ return view('videos.index', compact('playlists', 'filter', 'langChips') + [
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'videos' => collect(), 'shorts' => collect(), 'matches' => collect(),
]); ]);
} }
@ -49,7 +89,7 @@ class VideoController extends Controller
// ── Shorts-only browse ──────────────────────────────────── // ── Shorts-only browse ────────────────────────────────────
if ($filter === 'shorts') { if ($filter === 'shorts') {
$videos = Video::public()->shorts()->latest()->limit(60)->get(); $videos = Video::public()->shorts()->latest()->limit(60)->get();
return view('videos.index', compact('videos', 'filter') + [ return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]); ]);
} }
@ -57,7 +97,7 @@ class VideoController extends Controller
// ── Sports/Matches-only browse ──────────────────────────── // ── Sports/Matches-only browse ────────────────────────────
if ($filter === 'match') { if ($filter === 'match') {
$videos = Video::public()->where('type', 'match')->notShorts()->latest()->limit(60)->get(); $videos = Video::public()->where('type', 'match')->notShorts()->latest()->limit(60)->get();
return view('videos.index', compact('videos', 'filter') + [ return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]); ]);
} }
@ -68,7 +108,7 @@ class VideoController extends Controller
if ($filter === 'music') $query->where('type', 'music'); if ($filter === 'music') $query->where('type', 'music');
else $query->latest(); else $query->latest();
$videos = $query->limit(50)->get(); $videos = $query->limit(50)->get();
return view('videos.index', compact('videos', 'filter') + [ return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]); ]);
} }
@ -91,11 +131,58 @@ class VideoController extends Controller
->sortByDesc('date') ->sortByDesc('date')
->values(); ->values();
return view('videos.index', compact('feedItems', 'filter') + [ return view('videos.index', compact('feedItems', 'filter', 'langChips') + [
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), 'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]); ]);
} }
/**
* Distinct languages currently present in public content, sorted by how
* many videos each covers (primary + secondary tracks combined). Returned
* as [ ['code'=>'ja','flag'=>'jp','name'=>'Japanese','count'=>N], ... ]
*/
protected function buildLanguageChips(int $limit = 12): array
{
$publicIds = \App\Models\Video::public()->pluck('id');
if ($publicIds->isEmpty()) return [];
// Video-per-language: union of primary language and each track's
// language, deduplicated per video so a video with EN primary + EN
// extra doesn't inflate the EN count.
$primary = \App\Models\Video::public()
->whereNotNull('language')->where('language', '!=', '')
->pluck('language', 'id');
$tracks = \App\Models\VideoAudioTrack::whereIn('video_id', $publicIds)
->whereNotNull('language')->where('language', '!=', '')
->get(['video_id', 'language']);
$langsPerVideo = [];
foreach ($primary as $vid => $lang) {
$langsPerVideo[$vid][strtolower($lang)] = true;
}
foreach ($tracks as $t) {
$langsPerVideo[$t->video_id][strtolower($t->language)] = true;
}
$counts = [];
foreach ($langsPerVideo as $langs) {
foreach (array_keys($langs) as $code) {
$counts[$code] = ($counts[$code] ?? 0) + 1;
}
}
arsort($counts);
$chips = [];
foreach ($counts as $code => $count) {
$flag = \App\Data\Languages::flag($code);
if (! $flag) continue; // skip unknown codes
$name = \App\Data\Languages::all()[$code]['name'] ?? strtoupper($code);
$chips[] = ['code' => $code, 'flag' => $flag, 'name' => $name, 'count' => $count];
if (count($chips) >= $limit) break;
}
return $chips;
}
public function search(Request $request) public function search(Request $request)
{ {
$query = $request->get('q', ''); $query = $request->get('q', '');

View File

@ -157,6 +157,31 @@
font-size: 12px; font-size: 12px;
} }
.yt-video-card .yt-track-lang-badge {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 3px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 5px;
letter-spacing: 0.3px;
z-index: 3;
backdrop-filter: blur(4px);
}
.yt-video-card .yt-track-lang-badge .fi {
width: 16px;
height: 12px;
border-radius: 2px;
display: inline-block;
flex-shrink: 0;
}
.yt-video-card .yt-visibility-badge { .yt-video-card .yt-visibility-badge {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;

View File

@ -3,7 +3,15 @@
@php @php
use App\Data\Languages; use App\Data\Languages;
$videoUrl = $video ? route('videos.stream', $video) : null; // When a specific audio track is being surfaced (e.g. via the language
// filter chips), the hover-preview should play that language, not the
// primary. audio-track route serves the track file directly.
$forceTrackIdEarly = $video ? $video->getAttribute('_force_track_id') : null;
$videoUrl = $video
? ($forceTrackIdEarly
? route('videos.audio-track', ['video' => $video, 'track' => $forceTrackIdEarly])
: route('videos.stream', $video))
: null;
$thumbnailUrl = $video && $video->thumbnail $thumbnailUrl = $video && $video->thumbnail
? route('media.thumbnail', $video->thumbnail) ? route('media.thumbnail', $video->thumbnail)
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360'); : ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
@ -20,8 +28,18 @@ $isShorts = $video && $video->isShorts();
// Check if current user is the owner of the video // Check if current user is the owner of the video
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id; $isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
// Language flag code (null when no language set) // When surfacing a video via a secondary audio track (e.g. from the
$langFlag = $video ? Languages::flag($video->language) : null; // language filter chips) the controller tags it with these runtime
// attributes so the card links straight to that track and badges the
// thumbnail with its flag.
$forceTrackId = $video ? $video->getAttribute('_force_track_id') : null;
$forceTrackFlag = $video ? $video->getAttribute('_force_track_flag') : null;
// Language flag code (null when no language set). Prefer the forced
// track flag when set — it's the language the card is being surfaced for.
$langFlag = $forceTrackFlag ?: ($video ? Languages::flag($video->language) : null);
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
// Size classes // Size classes
$sizeClasses = match($size) { $sizeClasses = match($size) {
@ -31,7 +49,7 @@ $sizeClasses = match($size) {
@endphp @endphp
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}"> <div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
<a href="{{ $video ? route('videos.show', $video) : '#' }}"> <a href="{{ $showUrl }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)" <div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}"> data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')"> <img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@ -54,6 +72,12 @@ $sizeClasses = match($size) {
<i class="bi bi-collection-play-fill"></i> SHORTS <i class="bi bi-collection-play-fill"></i> SHORTS
</span> </span>
@endif @endif
@if($forceTrackId && $forceTrackFlag)
<span class="yt-track-lang-badge" title="Plays the {{ strtoupper($video->language ?? '') }} track's language variant">
<span class="fi fi-{{ $forceTrackFlag }}"></span>
<span>Track</span>
</span>
@endif
@if($isOwner && $video->visibility === 'private') @if($isOwner && $video->visibility === 'private')
<span class="yt-visibility-badge yt-visibility-private"> <span class="yt-visibility-badge yt-visibility-private">
<i class="bi bi-lock-fill"></i> Private <i class="bi bi-lock-fill"></i> Private
@ -74,7 +98,7 @@ $sizeClasses = match($size) {
</a> </a>
<div class="yt-video-details"> <div class="yt-video-details">
<h3 class="yt-video-title"> <h3 class="yt-video-title">
<a href="{{ $video ? route('videos.show', $video) : '#' }}"> <a href="{{ $showUrl }}">
@if($langFlag) @if($langFlag)
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span> <span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
@endif @endif

View File

@ -34,6 +34,10 @@
.yt-empty { text-align: center; padding: 80px 20px; } .yt-empty { text-align: center; padding: 80px 20px; }
/* Language chips */
.yt-chip-sep { display:inline-block; width:1px; align-self:stretch; margin:6px 4px; background:var(--border-color); }
.yt-chip-lang { display:inline-flex; align-items:center; gap:6px; }
.yt-chip-lang .fi { width:18px; height:13px; border-radius:2px; display:inline-block; flex-shrink:0; }
</style> </style>
@endsection @endsection
@ -57,6 +61,19 @@
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a> class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a>
<a href="{{ route('videos.index', ['filter' => 'latest']) }}" <a href="{{ route('videos.index', ['filter' => 'latest']) }}"
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a> class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a>
@if(!empty($langChips ?? []))
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
<span class="yt-chip-sep" aria-hidden="true"></span>
@foreach($langChips as $chip)
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
title="{{ $chip['name'] }} ({{ $chip['count'] }})">
<span class="fi fi-{{ $chip['flag'] }}"></span>
<span>{{ $chip['name'] }}</span>
</a>
@endforeach
@endif
</div> </div>
@endunless @endunless