From fcb752dd7637870c9f330f81733293bb6dad81a5 Mon Sep 17 00:00:00 2001 From: ghassan Date: Fri, 31 Jul 2026 03:27:42 +0300 Subject: [PATCH] Language filter chips on the videos feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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) --- app/Http/Controllers/VideoController.php | 97 ++++++++++++++++++- .../components/partials/card-styles.blade.php | 25 +++++ .../views/components/video-card.blade.php | 34 ++++++- resources/views/videos/index.blade.php | 17 ++++ 4 files changed, 163 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 54cb491..c9e6c11 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -33,6 +33,46 @@ class VideoController extends Controller { $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 ────────────────────────────────── if ($filter === 'playlists') { $playlists = Playlist::where('visibility', 'public') @@ -41,7 +81,7 @@ class VideoController extends Controller ->latest() ->limit(60) ->get(); - return view('videos.index', compact('playlists', 'filter') + [ + return view('videos.index', compact('playlists', 'filter', 'langChips') + [ 'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), ]); } @@ -49,7 +89,7 @@ class VideoController extends Controller // ── Shorts-only browse ──────────────────────────────────── if ($filter === 'shorts') { $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(), ]); } @@ -57,7 +97,7 @@ class VideoController extends Controller // ── Sports/Matches-only browse ──────────────────────────── if ($filter === 'match') { $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(), ]); } @@ -68,7 +108,7 @@ class VideoController extends Controller if ($filter === 'music') $query->where('type', 'music'); else $query->latest(); $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(), ]); } @@ -91,11 +131,58 @@ class VideoController extends Controller ->sortByDesc('date') ->values(); - return view('videos.index', compact('feedItems', 'filter') + [ + return view('videos.index', compact('feedItems', 'filter', 'langChips') + [ '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) { $query = $request->get('q', ''); diff --git a/resources/views/components/partials/card-styles.blade.php b/resources/views/components/partials/card-styles.blade.php index 50f64f0..91cc47b 100644 --- a/resources/views/components/partials/card-styles.blade.php +++ b/resources/views/components/partials/card-styles.blade.php @@ -157,6 +157,31 @@ 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 { position: absolute; bottom: 8px; diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php index 78feba5..7703733 100644 --- a/resources/views/components/video-card.blade.php +++ b/resources/views/components/video-card.blade.php @@ -3,7 +3,15 @@ @php 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 ? route('media.thumbnail', $video->thumbnail) : ($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 $isOwner = $video && auth()->check() && auth()->id() == $video->user_id; -// Language flag code (null when no language set) -$langFlag = $video ? Languages::flag($video->language) : null; +// When surfacing a video via a secondary audio track (e.g. from the +// 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 $sizeClasses = match($size) { @@ -31,7 +49,7 @@ $sizeClasses = match($size) { @endphp