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:
parent
8d27520bdb
commit
fcb752dd76
@ -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', '');
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user