ghassan fcb752dd76 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>
2026-07-31 03:27:42 +03:00

501 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@props(['video' => null, 'size' => 'medium'])
@php
use App\Data\Languages;
// 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');
$typeIcon = $video ? match($video->type) {
'music' => 'bi-music-note',
'match' => 'bi-trophy',
default => 'bi-film',
} : 'bi-film';
// Check if video is shorts
$isShorts = $video && $video->isShorts();
// Check if current user is the owner of the video
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
// 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) {
'small' => 'yt-video-card-sm',
default => '',
};
@endphp
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
<a href="{{ $showUrl }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
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')">
@if($videoUrl)
<video preload="none">
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video>
@endif
{{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay">
<div class="audio-eq">
<span></span><span></span><span></span><span></span><span></span>
</div>
</div>
@if($video && $video->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $video->duration) }}</span>
@endif
@if($isShorts)
<span class="yt-shorts-badge">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@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')
<span class="yt-visibility-badge yt-visibility-private">
<i class="bi bi-lock-fill"></i> Private
</span>
@elseif($isOwner && $video->visibility === 'unlisted')
<span class="yt-visibility-badge yt-visibility-unlisted">
<i class="bi bi-link-45deg"></i> Unlisted
</span>
@endif
</div>
</a>
<div class="yt-video-info">
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();">
@if($video && $video->user && $video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
@endif
</a>
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ $showUrl }}">
@if($langFlag)
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
@endif
{{ $video->title ?? 'Untitled Video' }}
</a>
</h3>
@if($video && $video->user)
<a href="{{ route('channel', $video->user->channel) }}"
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
@endif
@if($video)
<div class="yt-video-meta">
@if($video->type)
<span class="yt-type-label yt-type-{{ $video->type }}">
<i class="bi {{ $typeIcon }}"></i>
{{ ucfirst($video->type === 'match' ? 'Sports' : ($video->type === 'generic' ? 'Video' : $video->type)) }}
</span>
&nbsp;·&nbsp;
@endif
{{ number_format($video->view_count) }} views · {{ $video->created_at->diffForHumans() }}
</div>
@endif
</div>
@if($video)
<div class="position-relative">
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($isOwner)
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
<li>
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
<i class="bi bi-trash"></i> Delete
</button>
</li>
<li><hr class="dropdown-divider"></li>
@endif
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
<i class="bi bi-list-nested"></i> Add to queue
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
<i class="bi bi-clock"></i> Save to Watch later
</a>
</li>
<li>
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
<i class="bi bi-bookmark"></i> Save to playlist
</x-save-to-playlist-button>
</li>
@if($video->allow_download)
<li>
<a class="dropdown-item" href="{{ route('videos.download', $video) }}">
<i class="bi bi-download"></i> Download
</a>
</li>
@endif
@if($video->isShareable())
<li>
<x-share-button :video="$video" tag="a" class="dropdown-item">
<i class="bi bi-share"></i> Share
</x-share-button>
</li>
@endif
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
<i class="bi bi-dash-circle"></i> Not interested
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')">
<i class="bi bi-x-circle"></i> Don't recommend channel
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
<i class="bi bi-flag"></i> Report
</a>
</li>
</ul>
</div>
@endif
</div>
</div>
@php $vk = $video->getRouteKey() ?? ''; @endphp
<!-- Cute Edit Video Modal -->
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
<div class="modal-content cute-edit-content">
<div class="cute-edit-header">
<span class="cute-edit-icon">✏️</span>
<h5>Edit Video</h5>
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
</div>
<div class="cute-edit-body">
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Title -->
<div class="cute-form-group">
<label><i class="bi bi-card-heading"></i> Title</label>
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
</div>
<!-- Description -->
<div class="cute-form-group">
<label><i class="bi bi-text-paragraph"></i> Description</label>
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
</div>
<!-- Video Type -->
<div class="cute-form-group">
<label><i class="bi bi-collection-play"></i> Type</label>
<div class="cute-type-options">
<label class="cute-type-option active" data-type="generic">
<input type="radio" name="type" value="generic" checked>
<span>🎬 Generic</span>
</label>
<label class="cute-type-option" data-type="music">
<input type="radio" name="type" value="music">
<span>🎵 Music</span>
</label>
<label class="cute-type-option" data-type="match">
<input type="radio" name="type" value="match">
<span>🏆 Match</span>
</label>
</div>
</div>
<!-- Shorts Toggle -->
<div class="cute-form-group">
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
<label class="cute-shorts-toggle">
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
<span class="cute-shorts-slider"></span>
<span class="cute-shorts-label">Mark as Short</span>
</label>
</div>
<!-- Thumbnail -->
<div class="cute-form-group">
<label><i class="bi bi-image"></i> Thumbnail</label>
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
<i class="bi bi-camera"></i>
<span>Click to change</span>
</div>
</div>
</div>
<!-- Privacy -->
<div class="cute-form-group">
<label><i class="bi bi-shield-lock"></i> Privacy</label>
<div class="cute-privacy-options">
<label class="cute-privacy-option active" data-privacy="public">
<input type="radio" name="visibility" value="public" checked>
<span>🌐 Public</span>
</label>
<label class="cute-privacy-option" data-privacy="unlisted">
<input type="radio" name="visibility" value="unlisted">
<span>🔗 Unlisted</span>
</label>
<label class="cute-privacy-option" data-privacy="private">
<input type="radio" name="visibility" value="private">
<span>🔒 Private</span>
</label>
</div>
</div>
<!-- Status -->
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
<!-- Buttons -->
<div class="cute-edit-actions">
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
<button type="submit" class="cute-btn-save">Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
@include('components.partials.card-styles')
@once
<script>
// Global function to save to Watch Later
function saveToWatchLater(videoId) {
fetch(`/videos/${videoId}/watch-later`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message || 'Added to Watch Later', 'success');
}
})
.catch(error => console.error('Error:', error));
}
// Global function to add to queue
function addToQueue(videoId) {
showToast('Queue feature coming soon!', 'info');
}
function playVideo(element) {
const video = element.querySelector('video');
if (!video) return;
video.currentTime = 0;
const isAudio = element.dataset.audio === 'true';
video.volume = 0.5;
if (isAudio) {
// Keep thumbnail visible — just play audio, show equalizer
video.play().catch(function() {});
element.classList.add('audio-playing');
} else {
video.play().catch(function() {});
video.classList.add('active');
}
}
function stopVideo(element) {
const video = element.querySelector('video');
if (!video) return;
video.pause();
video.currentTime = 0;
video.classList.remove('active');
element.classList.remove('audio-playing');
}
// Edit Modal Functions
let currentEditVideoId = null;
function openEditVideoModal(videoId) {
currentEditVideoId = videoId;
const modalId = 'editVideoModal' + (videoId || '');
const modal = new bootstrap.Modal(document.getElementById(modalId));
modal.show();
// Fetch video data
fetch(`/videos/${videoId}/edit`, {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const video = data.video;
document.getElementById('edit-title-' + videoId).value = video.title || '';
document.getElementById('edit-description-' + videoId).value = video.description || '';
// Set type
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
typeOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.type === (video.type || 'generic')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set privacy
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
privacyOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.privacy === (video.visibility || 'public')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set shorts toggle
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
if (shortsCheckbox) {
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
}
// Clear status
const statusEl = document.getElementById('edit-status-' + videoId);
statusEl.className = 'cute-status';
statusEl.textContent = '';
}
})
.catch(error => {
console.error('Error:', error);
});
}
function closeEditVideoModal(videoId) {
const modalId = 'editVideoModal' + (videoId || '');
const modalEl = document.getElementById(modalId);
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
}
// SPA navigation re-executes this once-protected script on every page swap.
// Guard the document-level listeners so they don't stack up across navs.
if (!window._videoCardListenersBound) {
window._videoCardListenersBound = true;
// Type option click handlers
document.addEventListener('click', function(e) {
if (e.target.closest('.cute-type-option')) {
const option = e.target.closest('.cute-type-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
if (e.target.closest('.cute-privacy-option')) {
const option = e.target.closest('.cute-privacy-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
});
// Thumbnail preview
document.addEventListener('change', function(e) {
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
const file = e.target.files[0];
if (file) {
const videoId = e.target.id.replace('edit-thumbnail-', '');
const preview = document.getElementById('thumbnail-preview-' + videoId);
preview.innerHTML = `<span>${file.name}</span>`;
}
}
});
// Form submission
document.addEventListener('submit', function(e) {
const form = e.target;
if (form.id && form.id.startsWith('edit-video-form-')) {
e.preventDefault();
const videoId = form.id.replace('edit-video-form-', '');
const formData = new FormData(form);
const statusEl = document.getElementById('edit-status-' + videoId);
const submitBtn = form.querySelector('.cute-btn-save');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
fetch(`/videos/${videoId}`, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusEl.className = 'cute-status success';
statusEl.textContent = '✓ Saved successfully!';
setTimeout(() => {
closeEditVideoModal(videoId);
window.location.reload();
}, 1000);
} else {
throw new Error(data.message || 'Update failed');
}
})
.catch(error => {
statusEl.className = 'cute-status error';
statusEl.textContent = '✗ ' + error.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
});
}
});
} // end _videoCardListenersBound guard
</script>
@endonce