Add NAS file manager integration and all pending platform changes

- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-13 13:24:32 +03:00
1 parent d44490dfe0
commit 0b2e95ea65
161 files changed
+27496 -7250

No files matched your search

+335 -113
View File
@@ -11,16 +11,17 @@
@media (max-width: 991px) {
.upload-page-only .yt-header, .upload-page-only .yt-sidebar { display: block !important; }
.upload-page-only .yt-main { display: block !important; margin-left: 0; }
.upload-page-only .yt-main { display: block !important; margin-left: 0; padding-bottom: 80px; }
.upload-page-only.upload-page-responsive { display: block; background: transparent; padding: 0; }
.upload-page-only.upload-page-responsive .upload-page-standalone { max-width: 100%; border-radius: 0; box-shadow: none; background: transparent; border: none; }
.upload-page-only.upload-page-responsive .upload-page-standalone { max-width: 100%; border-radius: 0; box-shadow: none; background: var(--bg-primary); border: none; padding: 16px; }
.upload-page-only.upload-page-responsive .upload-page-header { display: none !important; }
.upload-page-only.upload-page-responsive .upload-page-body { padding: 0; background: transparent; }
.upload-page-only.upload-page-responsive .form-input, .upload-page-only.upload-page-responsive .form-textarea { background: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); }
.upload-page-only.upload-page-responsive .form-input,
.upload-page-only.upload-page-responsive .form-textarea { background: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); }
.upload-page-only.upload-page-responsive .form-label { color: var(--text-primary); }
.upload-page-only.upload-page-responsive .visibility-content-modal { background: var(--bg-secondary); border-color: var(--border-color); }
.upload-page-only.upload-page-responsive .visibility-title { color: var(--text-primary); }
.upload-page-only.upload-page-responsive .visibility-desc { color: var(--text-secondary); }
#dropzone { padding: 40px 16px; }
.options-grid { flex-direction: column; }
.option-item { min-width: 100%; }
}
@media (min-width: 992px) {
@@ -123,18 +124,11 @@
/* Submit Button */
.btn-submit {
width: 100%;
background: var(--brand-red);
color: white;
border: none;
padding: 14px 24px;
border-radius: 8px;
justify-content: center;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover { background: #cc1a1a; }
.btn-submit:disabled { background: #555; cursor: not-allowed; }
.btn-submit:disabled { background: #555 !important; border-color: #555 !important; cursor: not-allowed; opacity: 1; }
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
@@ -152,16 +146,16 @@
<!-- Video File Dropzone -->
<div class="form-group">
<label class="form-label">Video File *</label>
<label class="form-label"><i class="bi bi-camera-video" id="file-label-icon"></i> <span id="file-label-text">Video or Music File *</span></label>
<div id="dropzone">
<input type="file" name="video" id="video" accept="video/*" required>
<input type="file" name="video" id="video" accept="video/*,audio/mpeg,audio/mp4,audio/aac,audio/flac,audio/wav,.mp3,.m4a,.aac,.flac,.wav" required>
<div id="dropzone-default">
<div class="icon"><i class="bi bi-cloud-arrow-up"></i></div>
<p>Click to select or drag video here</p>
<p class="hint">MP4, MOV, AVI, WebM and more</p>
<p>Click to select or drag file here</p>
<p class="hint">Video: MP4, MOV, AVI, WebM — Audio: MP3, M4A, AAC, FLAC</p>
</div>
<div id="file-info">
<div class="file-icon"><i class="bi bi-film"></i></div>
<div class="file-icon"><i class="bi bi-film" id="file-preview-icon"></i></div>
<div class="file-details">
<p class="filename" id="filename"></p>
<p class="filesize" id="filesize"></p>
@@ -183,17 +177,48 @@
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea>
</div>
<!-- Thumbnail -->
<div class="form-group">
<label class="form-label">Thumbnail</label>
<!-- Thumbnail (video) -->
<div class="form-group" id="thumbnail-section">
<label class="form-label"><i class="bi bi-image"></i> <span id="thumbnail-label-text">Thumbnail</span></label>
<div id="thumbnail-dropzone">
<input type="file" name="thumbnail" id="thumbnail" accept="image/*">
<div id="thumbnail-default">
<i class="bi bi-card-image"></i>
<p>Click to select thumbnail (optional)</p>
<p id="thumbnail-dropzone-hint">Click to select thumbnail (optional, max 20MB)</p>
</div>
<div id="thumbnail-info">
<img id="thumbnail-preview-img" src="" alt="Thumbnail preview">
<div style="display:flex;align-items:center;gap:12px;padding:12px;background:#1f1f1f;border-radius:8px;border:1px solid #333;">
<div style="width:80px;height:48px;overflow:hidden;border-radius:6px;flex-shrink:0;">
<img id="thumbnail-preview-img" src="" alt="Thumbnail preview" style="width:100%;height:100%;object-fit:cover;">
</div>
<div style="flex:1;text-align:left;min-width:0;">
<p id="thumbnail-filename" style="color:#e5e5e5;font-weight:500;font-size:13px;margin:0 0 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></p>
<p id="thumbnail-filesize" style="color:#888;font-size:12px;margin:0;"></p>
</div>
<button type="button" class="btn-remove" onclick="removeThumbnail(event)" style="flex-shrink:0;"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
<!-- Slides dropzone (audio only) -->
<div class="form-group" id="slides-section" style="display:none;">
<label class="form-label">
<i class="bi bi-images"></i> Slides <span style="color:var(--brand-red);">*</span>
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:6px;">Multiple images will crossfade while playing</span>
</label>
<div id="slides-dropzone" style="border:2px dashed var(--brand-red);border-radius:10px;padding:12px;cursor:pointer;text-align:center;min-height:72px;">
<input type="file" name="slides[]" id="slides-input" accept="image/*" multiple style="display:none;">
<div id="slides-default">
<i class="bi bi-images" style="font-size:28px;color:var(--text-secondary);"></i>
<p style="color:var(--text-secondary);font-size:13px;margin:4px 0 0;">Click or drag to add cover images</p>
</div>
<div id="slides-preview" style="display:none;">
<div id="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:0 4px;">
<span id="slides-count" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="removeSlides(event)" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;"><i class="bi bi-x-lg"></i> Clear</button>
</div>
</div>
</div>
</div>
@@ -256,7 +281,44 @@
<!-- Hidden Shorts - Auto-detected -->
<input type="hidden" name="is_shorts" id="is_shorts" value="0">
<!-- Download Access -->
<div class="form-group" style="margin-bottom: 16px;">
<label class="form-label">
<i class="bi bi-download"></i> Who can download?
</label>
<div class="options-grid" id="download-options">
<label class="option-item active" data-download="disabled">
<input type="radio" name="download_access" value="disabled" checked>
<div class="option-content">
<i class="bi bi-slash-circle"></i>
<span>Disabled</span>
</div>
</label>
<label class="option-item" data-download="everyone">
<input type="radio" name="download_access" value="everyone">
<div class="option-content">
<i class="bi bi-globe"></i>
<span>Everyone</span>
</div>
</label>
<label class="option-item" data-download="registered">
<input type="radio" name="download_access" value="registered">
<div class="option-content">
<i class="bi bi-person-check"></i>
<span>Members</span>
</div>
</label>
<label class="option-item" data-download="subscribers">
<input type="radio" name="download_access" value="subscribers">
<div class="option-content">
<i class="bi bi-star"></i>
<span>Subscribers</span>
</div>
</label>
</div>
</div>
<!-- Progress Bar -->
<div id="progress-container">
<div class="progress-bar-wrapper">
@@ -269,8 +331,8 @@
<div id="status-message"></div>
<!-- Submit Button -->
<button type="submit" id="submit-btn" class="btn-submit">
<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video
<button type="submit" id="submit-btn" class="action-btn action-btn-primary btn-submit">
<i class="bi bi-cloud-arrow-up-fill"></i> <span>Upload Video</span>
</button>
</form>
</div>
@@ -279,142 +341,302 @@
@section('scripts')
<script>
// Video Dropzone
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('video');
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('video');
let thumbnailInput = document.getElementById('thumbnail');
const thumbnailDropzone = document.getElementById('thumbnail-dropzone');
const slidesDropzone = document.getElementById('slides-dropzone');
let slidesInput = document.getElementById('slides-input');
let currentFile = null;
fileInput.addEventListener('change', function() { handleFileSelect(this); });
dropzone.addEventListener('click', function(e) { if (e.target.classList.contains('btn-remove')) return; fileInput.click(); });
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleFileSelect(fileInput); }
});
function handleFileSelect(input) {
if (input.files && input.files[0]) {
const file = input.files[0];
currentFile = file;
// Set title from filename
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
document.getElementById('video-title').value = fileNameWithoutExt;
document.getElementById('filename').textContent = file.name;
document.getElementById('filesize').textContent = formatFileSize(file.size);
document.getElementById('dropzone-default').style.display = 'none';
document.getElementById('file-info').classList.add('active');
}
}
// ── Helpers ──────────────────────────────────────────────
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const k = 1024, sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
const AUDIO_EXTS = ['mp3', 'm4a', 'aac', 'flac', 'wav'];
function isAudioFile(file) {
const ext = file.name.split('.').pop().toLowerCase();
return AUDIO_EXTS.includes(ext) || file.type.startsWith('audio/');
}
function setAudioMode(audio) {
document.getElementById('file-label-icon').className = audio ? 'bi bi-music-note-beamed' : 'bi bi-camera-video';
document.getElementById('file-label-text').textContent = audio ? 'Music File *' : 'Video or Music File *';
// Toggle thumbnail vs slides section
document.getElementById('thumbnail-section').style.display = audio ? 'none' : '';
document.getElementById('slides-section').style.display = audio ? '' : 'none';
slidesDropzone.style.borderColor = audio ? 'var(--brand-red)' : '';
if (audio) {
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
const musicOpt = document.querySelector('#type-options .option-item[data-type="music"]');
if (musicOpt) { musicOpt.classList.add('active'); musicOpt.querySelector('input').checked = true; }
}
document.getElementById('file-preview-icon').className = audio ? 'bi bi-music-note-beamed' : 'bi bi-film';
document.getElementById('submit-btn').innerHTML =
'<i class="bi bi-cloud-arrow-up-fill"></i> <span>' + (audio ? 'Upload Track' : 'Upload Video') + '</span>';
}
// ── Video/Audio File ─────────────────────────────────────
fileInput.addEventListener('change', function() { handleFileSelect(this); });
dropzone.addEventListener('click', function(e) {
if (e.target.closest('.btn-remove')) return;
fileInput.click();
});
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('dragover'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop', e => {
e.preventDefault(); dropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleFileSelect(fileInput); }
});
function handleFileSelect(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const maxSize = 512 * 1024 * 1024;
const validVideoTypes = ['video/mp4','video/webm','video/ogg','video/quicktime','video/x-msvideo','video/x-flv','video/x-matroska'];
const validVideoExts = ['.mp4','.webm','.ogg','.mov','.avi','.wmv','.flv','.mkv'];
const ext = '.' + file.name.split('.').pop().toLowerCase();
const audio = isAudioFile(file);
const validVideo = validVideoTypes.includes(file.type) || validVideoExts.includes(ext);
if (!audio && !validVideo) {
showError('Invalid format. Accepted: MP4, MOV, AVI, WebM, MKV, MP3, M4A, AAC, FLAC, WAV.');
input.value = ''; return;
}
if (file.size > maxSize) {
showError('File size exceeds 512MB limit.');
input.value = ''; return;
}
currentFile = file;
setAudioMode(audio);
const titleWithoutExt = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
document.getElementById('video-title').value = titleWithoutExt;
document.getElementById('filename').textContent = file.name;
document.getElementById('filesize').textContent = formatFileSize(file.size);
document.getElementById('dropzone-default').style.display = 'none';
document.getElementById('file-info').classList.add('active');
}
function removeVideo(e) {
e.preventDefault();
e.stopPropagation();
fileInput.value = '';
currentFile = null;
e.preventDefault(); e.stopPropagation();
fileInput.value = ''; currentFile = null;
document.getElementById('dropzone-default').style.display = 'block';
document.getElementById('file-info').classList.remove('active');
document.getElementById('video-title').value = '';
setAudioMode(false);
}
// Thumbnail
const thumbnailInput = document.getElementById('thumbnail');
const thumbnailDropzone = document.getElementById('thumbnail-dropzone');
thumbnailInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('thumbnail-preview-img').src = e.target.result;
};
reader.readAsDataURL(this.files[0]);
document.getElementById('thumbnail-default').style.display = 'none';
document.getElementById('thumbnail-info').classList.add('active');
// ── Thumbnail / Cover Art ─────────────────────────────────
let _thumbClickTs = 0;
thumbnailInput.addEventListener('change', function() { _thumbClickTs = 0; handleThumbnailSelect(this); });
thumbnailDropzone.addEventListener('click', function(e) {
if (e.target.closest('.btn-remove')) return;
const now = Date.now();
if (now - _thumbClickTs < 800) return;
_thumbClickTs = now;
if (typeof window.openCropperModal_thumb_create_mobile === 'function') {
window.openCropperModal_thumb_create_mobile();
} else {
thumbnailInput.click();
}
});
// Option handlers
thumbnailDropzone.addEventListener('dragover', e => { e.preventDefault(); thumbnailDropzone.classList.add('dragover'); });
thumbnailDropzone.addEventListener('dragleave', () => thumbnailDropzone.classList.remove('dragover'));
thumbnailDropzone.addEventListener('drop', e => {
e.preventDefault(); thumbnailDropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
const droppedFile = e.dataTransfer.files[0];
if (typeof window.tcPreload_thumb_create_mobile === 'function') {
window.tcPreload_thumb_create_mobile(droppedFile);
window.openCropperModal_thumb_create_mobile();
} else {
thumbnailInput.files = e.dataTransfer.files;
handleThumbnailSelect(thumbnailInput);
}
}
});
function handleThumbnailSelect(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
document.getElementById('thumbnail-filename').textContent = file.name;
document.getElementById('thumbnail-filesize').textContent = formatFileSize(file.size);
const reader = new FileReader();
reader.onload = e => { document.getElementById('thumbnail-preview-img').src = e.target.result; };
reader.readAsDataURL(file);
document.getElementById('thumbnail-default').style.display = 'none';
document.getElementById('thumbnail-info').classList.add('active');
}
function removeThumbnail(e) {
e.preventDefault(); e.stopPropagation();
const fresh = thumbnailInput.cloneNode(false);
thumbnailInput.parentNode.replaceChild(fresh, thumbnailInput);
thumbnailInput = fresh;
thumbnailInput.addEventListener('change', function() { handleThumbnailSelect(this); });
document.getElementById('thumbnail-default').style.display = 'block';
document.getElementById('thumbnail-info').classList.remove('active');
}
// ── Slides dropzone (audio mode) ─────────────────────────
slidesDropzone.addEventListener('click', e => { if (e.target.closest('button')) return; slidesInput.click(); });
slidesInput.addEventListener('change', function() { handleSlidesSelect(this); });
slidesDropzone.addEventListener('dragover', e => { e.preventDefault(); slidesDropzone.classList.add('dragover'); });
slidesDropzone.addEventListener('dragleave', () => slidesDropzone.classList.remove('dragover'));
slidesDropzone.addEventListener('drop', e => {
e.preventDefault(); slidesDropzone.classList.remove('dragover');
if (e.dataTransfer.files.length) { slidesInput.files = e.dataTransfer.files; handleSlidesSelect(slidesInput); }
});
function handleSlidesSelect(input) {
if (!input.files || !input.files.length) return;
const files = Array.from(input.files);
const strip = document.getElementById('slides-strip');
strip.innerHTML = '';
files.forEach(f => {
const reader = new FileReader();
reader.onload = e => {
const img = document.createElement('img');
img.src = e.target.result;
img.style.cssText = 'width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.15);';
strip.appendChild(img);
};
reader.readAsDataURL(f);
});
document.getElementById('slides-count').textContent =
files.length === 1 ? '1 image — static cover' : files.length + ' images — will crossfade';
document.getElementById('slides-default').style.display = 'none';
document.getElementById('slides-preview').style.display = '';
slidesDropzone.style.borderColor = '#22c55e';
}
function removeSlides(e) {
e.preventDefault(); e.stopPropagation();
const fresh = slidesInput.cloneNode(false);
slidesInput.parentNode.replaceChild(fresh, slidesInput);
slidesInput = fresh;
slidesInput.addEventListener('change', function() { handleSlidesSelect(this); });
document.getElementById('slides-default').style.display = 'block';
document.getElementById('slides-preview').style.display = 'none';
slidesDropzone.style.borderColor = 'var(--brand-red)';
}
// ── Option selectors ─────────────────────────────────────
document.querySelectorAll('#type-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', function() {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
document.querySelectorAll('#visibility-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', function() {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#visibility-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
// Form submission
document.querySelectorAll('#download-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#download-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
});
});
// ── Form submission ───────────────────────────────────────
document.getElementById('upload-form').addEventListener('submit', function(e) {
e.preventDefault();
if (!currentFile) {
showError('Please select a video file');
return;
if (!currentFile) { showError('Please select a video or audio file.'); return; }
const title = document.getElementById('video-title').value.trim();
if (!title) { showError('Please enter a title.'); return; }
if (isAudioFile(currentFile)) {
if (!slidesInput.files || !slidesInput.files.length) {
showError('At least one cover image is required for audio tracks.');
slidesDropzone.style.borderColor = 'var(--brand-red)';
return;
}
}
const formData = new FormData(this);
const xhr = new XMLHttpRequest();
xhr.timeout = 1800000; // 30 min
document.getElementById('progress-container').classList.add('active');
document.getElementById('submit-btn').disabled = true;
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
document.getElementById('status-message').className = '';
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-text').textContent = 'Uploading... ' + percent + '%';
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-text').textContent = 'Uploading... ' + pct + '%';
}
});
xhr.addEventListener('load', function() {
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('progress-text').textContent = 'Processing...';
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
document.getElementById('progress-text').textContent = 'Done! Redirecting...';
window.location.href = response.redirect;
} else {
showError(response.message || 'Upload failed');
}
} catch(e) {
showError('Invalid response from server');
if (xhr.status >= 200 && xhr.status < 300) {
try {
const res = JSON.parse(xhr.responseText);
if (res.success) {
document.getElementById('progress-text').textContent = 'Done! Redirecting...';
window.location.href = res.redirect;
} else {
showError(res.message || 'Upload failed.');
}
} catch(err) { showError('Invalid response from server.'); }
} else {
try {
const res = JSON.parse(xhr.responseText);
if (res.errors) {
const msgs = Object.entries(res.errors).map(([f, errs]) => `<b>${f}:</b> ${errs.join(', ')}`).join('<br>');
showError(msgs);
} else {
showError(res.message || `Server error (${xhr.status})`);
}
} catch(err) { showError(`Upload failed (${xhr.status}).`); }
}
});
xhr.addEventListener('error', function() { showError('Upload failed. Please try again.'); });
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
xhr.addEventListener('timeout', () => showError('Upload timed out. The file may be too large.'));
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
});
function showError(message) {
const status = document.getElementById('status-message');
status.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> ' + message;
status.className = 'error';
document.getElementById('submit-btn').disabled = false;
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video';
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-cloud-arrow-up-fill"></i> <span>Upload Video</span>';
document.getElementById('progress-container').classList.remove('active');
}
</script>
<x-image-cropper
id="thumb_create_mobile"
:width="448"
:height="252"
shape="square"
target-input="thumbnail"
preview-img="thumbnail-preview-img"
output-width="1280"
title="Crop Thumbnail"
/>
@endsection
File diff suppressed because it is too large. Load diff
+196 -38
View File
@@ -1,62 +1,220 @@
@extends('layouts.app')
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' . config('app.name'))
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : config('app.name'))
@section('extra_styles')
<style>
/* Search info */
.search-info {
margin-bottom: 20px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 12px;
}
<style>
/* ── Regular video grid ── */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
.search-info h2 {
font-size: 20px;
margin: 0;
}
.search-info p {
color: var(--text-secondary);
margin: 8px 0 0;
}
/* ── Playlist count badge (right strip on thumbnail) ── */
.pl-count-badge {
position: absolute; inset: 0 0 0 auto;
width: 72px;
background: rgba(0,0,0,.78);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 4px; font-size: 12px; font-weight: 600; color: #fff;
pointer-events: none;
}
.pl-count-badge i { font-size: 20px; }
/* Video Grid */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
/* ── Playlist type badge (top-left on thumbnail) ── */
.feed-pl-type-badge {
position: absolute; top: 8px; left: 8px;
background: rgba(0,0,0,.75); color: #fff;
font-size: 11px; font-weight: 700;
padding: 3px 8px; border-radius: 4px;
display: flex; align-items: center; gap: 4px;
text-transform: uppercase; letter-spacing: .4px;
pointer-events: none;
}
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; } }
/* ── Thumbnail orientation fix ── */
.yt-video-card .yt-video-thumb img { object-fit: cover; }
/* Other styles unchanged */
.yt-empty { text-align: center; padding: 80px 20px; }
</style>
/* ── Search result helpers ── */
.search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; }
.search-info h2 { font-size: 20px; margin: 0; }
.search-info p { color: var(--text-secondary); margin: 8px 0 0; }
.results-section-title {
font-size: 15px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: .05em;
margin: 28px 0 14px;
display: flex; align-items: center; gap: 8px;
}
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
.yt-empty { text-align: center; padding: 80px 20px; }
</style>
@endsection
@section('content')
@isset($query)
<div class="search-info">
<h2>Search results for "{{ $query }}"</h2>
<p>{{ $videos->count() }} videos found</p>
</div>
@endisset
@if ($videos->isEmpty())
{{-- ── Filter chip bar ── --}}
@unless(isset($query))
@php $activeFilter = request('filter', 'all'); @endphp
<div class="yt-filter-bar">
<a href="{{ route('videos.index') }}"
class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a>
<a href="{{ route('videos.trending') }}"
class="yt-chip {{ request()->is('trending') ? 'active' : '' }}">Trending</a>
<a href="{{ route('videos.index', ['filter' => 'music']) }}"
class="yt-chip {{ $activeFilter === 'music' ? 'active' : '' }}">Music</a>
<a href="{{ route('videos.index', ['filter' => 'match']) }}"
class="yt-chip {{ $activeFilter === 'match' ? 'active' : '' }}">Sports</a>
<a href="{{ route('videos.shorts') }}"
class="yt-chip {{ request()->is('shorts') ? 'active' : '' }}">Shorts</a>
<a href="{{ route('videos.index', ['filter' => 'playlists']) }}"
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a>
<a href="{{ route('videos.index', ['filter' => 'latest']) }}"
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a>
</div>
@endunless
{{-- ══════════════════════════════════════════════
SEARCH RESULTS
══════════════════════════════════════════════ --}}
@isset($query)
@php
$searchShorts = $shorts ?? collect();
$searchPlaylists = $playlists ?? collect();
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count();
@endphp
<div class="search-info">
<h2>Search results for "{{ $query }}"</h2>
<p>
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
@if($searchShorts->count()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
@if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
@if($totalResults === 0) No results found @endif
</p>
</div>
@if($videos->count())
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
<div class="yt-video-grid">
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@if($searchShorts->count())
<div class="results-section-title"><i class="bi bi-lightning-charge-fill"></i> Shorts</div>
<div class="yt-video-grid">
@foreach($searchShorts as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@if($searchPlaylists->count())
<div class="results-section-title"><i class="bi bi-collection-play"></i> Playlists</div>
<div class="yt-video-grid">
@foreach($searchPlaylists as $pl)
@include('components.playlist-card', ['playlist' => $pl])
@endforeach
</div>
@endif
@if($totalResults === 0)
<div class="yt-empty">
<i class="bi bi-search" style="font-size:56px;color:var(--text-secondary);display:block;margin-bottom:12px;"></i>
<h2>No results for "{{ $query }}"</h2>
<p style="color:var(--text-secondary);">Try different keywords or browse the gallery.</p>
</div>
@endif
@endisset
{{-- ══════════════════════════════════════════════
PLAYLISTS-ONLY FILTER
══════════════════════════════════════════════ --}}
@if(!isset($query) && isset($filter) && $filter === 'playlists')
@if(isset($playlists) && $playlists->count())
<div class="yt-video-grid">
@foreach($playlists as $pl)
@include('components.playlist-card', ['playlist' => $pl])
@endforeach
</div>
@else
<div class="yt-empty">
<h2>No videos found</h2>
<i class="bi bi-collection-play" style="font-size:56px;color:var(--text-secondary);display:block;margin-bottom:12px;"></i>
<h2>No public playlists yet</h2>
</div>
@endif
{{-- ══════════════════════════════════════════════
HOME — MIXED FEED
══════════════════════════════════════════════ --}}
@elseif(!isset($query) && (!isset($filter) || $filter === 'all'))
@if(isset($feedItems) && $feedItems->count())
<div class="yt-video-grid">
@foreach($feedItems as $entry)
@if($entry['kind'] === 'video')
@include('components.video-card', ['video' => $entry['item']])
@else
@php $pl = $entry['item']; @endphp
@include('components.playlist-card', ['playlist' => $pl, 'showTypeBadge' => true])
@endif
@endforeach
</div>
@else
<div class="yt-empty">
<h2>No content yet</h2>
@auth
<a href="{{ route('videos.create') }}" class="btn btn-primary">Upload First Video</a>
<a href="{{ route('videos.create') }}" class="action-btn action-btn-primary" style="margin-top:12px;">Upload First Video</a>
@endauth
</div>
@endif
{{-- ══════════════════════════════════════════════
SINGLE-TYPE FILTERED VIEWS
══════════════════════════════════════════════ --}}
@elseif(!isset($query))
@if($videos->isEmpty())
<div class="yt-empty">
<i class="bi bi-camera-video" style="font-size:56px;color:var(--text-secondary);display:block;margin-bottom:12px;"></i>
<h2>Nothing here yet</h2>
</div>
@else
<div class="yt-video-grid">
@foreach ($videos as $video)
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@endforeach
</div>
@endif
@endif
@endsection
@section('scripts')
<script>
(function () {
function adjustPlThumb(img) {
img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover';
}
document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
if (img.complete && img.naturalWidth) {
adjustPlThumb(img);
} else {
img.addEventListener('load', function () { adjustPlThumb(img); });
}
});
})();
</script>
@endsection
@@ -0,0 +1,843 @@
@php
$audioUrl = route('videos.stream', $video);
$coverUrl = $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : asset('storage/images/logo.png');
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
$slideUrls = $video->slides->count() > 1
? $video->slides->map(fn($s) => asset('storage/thumbnails/' . $s->filename))->values()->all()
: [];
@endphp
<div class="ytp-wrap" id="ytpWrap">
<div class="ytp audio-ytp" id="audioContainer" tabindex="0">
{{-- Cover art / slideshow --}}
@if(count($slideUrls) > 1)
<div class="slideshow-wrap" id="slideshowWrap">
<img src="{{ $slideUrls[0] }}" alt="" class="slide-img slide-a" id="slideA">
<img src="{{ $slideUrls[1] }}" alt="" class="slide-img slide-b" id="slideB">
</div>
@else
<img src="{{ $coverUrl }}" alt="{{ $video->title }}" class="audio-cover-img" id="audioCoverImg">
@endif
{{-- Bars canvas overlay --}}
<canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas>
{{-- Gradient --}}
<div class="ytp-gradient-bottom"></div>
{{-- Large play overlay --}}
<div class="ytp-large-play-btn" id="ytpLargePlay">
<i class="bi bi-play-fill"></i>
</div>
{{-- Controls --}}
<div class="ytp-chrome-bottom" id="ytpControls">
<div class="ytp-progress-bar-container" id="ytpProgressContainer">
<div class="ytp-progress-bar" id="ytpProgressBar">
<div class="ytp-play-progress" id="ytpPlayed"></div>
<div class="ytp-scrubber-container">
<div class="ytp-scrubber-button" id="ytpScrubber"></div>
</div>
<div class="ytp-hover-time" id="ytpHoverTime"></div>
</div>
</div>
<div class="ytp-chrome-controls">
<div class="ytp-left-controls">
<button class="ytp-button ytp-play-btn" id="ytpPlayBtn" title="Play (k)">
<svg class="ytp-svg-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg class="ytp-svg-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
@if(isset($previousVideo) && $prevUrl)
<button class="ytp-button" title="Previous" onclick="window.location.href='{{ $prevUrl }}'">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
@endif
@if(isset($nextVideo) && $nextUrl)
<button class="ytp-button" title="Next" onclick="window.location.href='{{ $nextUrl }}'">
<svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
@endif
<div class="ytp-volume-area">
<button class="ytp-button ytp-mute-btn" id="ytpMuteBtn" title="Mute (m)">
<svg class="ytp-svg-vol3" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
<svg class="ytp-svg-vol0" viewBox="0 0 24 24" style="display:none"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"/></svg>
</button>
<div class="ytp-volume-slider-wrap">
<input type="range" class="ytp-volume-range" id="ytpVolume" min="0" max="100" step="1" value="50">
</div>
</div>
<div class="ytp-time-display">
<span id="ytpCurrent">0:00</span>
<span class="ytp-time-sep"> / </span>
<span id="ytpDuration">0:00</span>
</div>
</div>
<div class="ytp-right-controls">
<div class="ytp-settings-wrap" id="ytpSettingsWrap">
<button class="ytp-button ytp-settings-btn" id="ytpSettingsBtn" title="Settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
</button>
<div class="ytp-settings-panel" id="ytpSettingsPanel">
<div class="ytp-settings-item" id="ytpSpeedRow">
<svg viewBox="0 0 24 24"><path d="M10 8v8l6-4-6-4zm6.5 4A6.5 6.5 0 1 1 9 6.04V4.02A8.5 8.5 0 1 0 18.5 12H16.5z"/></svg>
<span>Playback speed</span>
<span class="ytp-settings-val" id="ytpSpeedLabel">Normal</span>
<svg class="ytp-chevron" viewBox="0 0 24 24"><path d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</div>
<div class="ytp-speed-panel" id="ytpSpeedPanel">
<div class="ytp-speed-back" id="ytpSpeedBack">
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Playback speed
</div>
@foreach([['0.25','0.25'],['0.5','0.5'],['0.75','0.75'],['1','Normal'],['1.25','1.25'],['1.5','1.5'],['1.75','1.75'],['2','2']] as [$val,$label])
<div class="ytp-speed-option {{ $val === '1' ? 'active' : '' }}" data-speed="{{ $val }}">
<svg class="ytp-speed-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
{{ $label }}
</div>
@endforeach
</div>
{{-- Loop toggle --}}
<div class="ytp-settings-item ytp-toggle-row" id="ytpLoopRow">
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
<span>Loop</span>
<span class="ytp-settings-val ytp-toggle-val" id="ytpLoopVal">Off</span>
<div class="ytp-toggle-switch"><div class="ytp-toggle-thumb"></div></div>
</div>
</div>
</div>
{{-- Bars visualiser toggle --}}
<button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off">
<svg viewBox="0 0 24 24">
<rect x="2" y="14" width="4" height="8" rx="1" fill="white"/>
<rect x="8" y="8" width="4" height="14" rx="1" fill="white"/>
<rect x="14" y="11" width="4" height="11" rx="1" fill="white"/>
<rect x="20" y="5" width="2" height="17" rx="1" fill="white"/>
</svg>
</button>
<button class="ytp-button ytp-fs-btn" id="ytpFsBtn" title="Full screen (f)">
<svg class="ytp-svg-fs-enter" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
<svg class="ytp-svg-fs-exit" viewBox="0 0 24 24" style="display:none"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
{{-- Hidden audio element --}}
<audio id="audioEl" src="{{ $audioUrl }}" preload="metadata"></audio>
{{-- ══ CSS ══ --}}
<style>
.audio-ytp { cursor: default; }
.audio-cover-img {
position: absolute;
inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block;
}
/* Slideshow */
.slideshow-wrap { position: absolute; inset: 0; background: #000; }
.slide-img {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block;
transition: opacity 1s ease-in-out;
}
.slide-img.portrait { object-fit: contain; }
.slide-a { opacity: 1; z-index: 1; }
.slide-b { opacity: 0; z-index: 0; }
.audio-ytp .ytp-gradient-bottom,
.audio-ytp .ytp-chrome-bottom,
.audio-ytp .ytp-large-play-btn { z-index: 4; }
/* Bars button: dim when off, red when on */
.audio-bars-btn svg { opacity: .45; transition: opacity .15s; }
.audio-bars-btn.bars-on svg { opacity: 1; fill: #f00 !important; }
.audio-bars-btn.bars-on rect { fill: #f00 !important; }
/* ══ Full ytp styles ══ */
.ytp-wrap {
position: relative; width: 100%; background: #000;
border-radius: 12px; overflow: hidden;
aspect-ratio: 16/9; max-height: 70vh;
}
.ytp {
position: relative; width: 100%; height: 100%;
background: #000; outline: none;
user-select: none; overflow: hidden;
font-family: Roboto, Arial, sans-serif;
}
.ytp:focus { outline: none; }
.ytp-gradient-bottom {
position: absolute; bottom: 0; left: 0; right: 0;
height: 98px;
background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,.75));
pointer-events: none; transition: opacity .25s;
}
.ytp-large-play-btn {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; opacity: 0; transition: opacity .2s;
}
.ytp-large-play-btn i { font-size: 72px; color: rgba(255,255,255,.9); text-shadow: 0 0 30px rgba(0,0,0,.6); }
.ytp-large-play-btn.visible { opacity: 1; }
.ytp-chrome-bottom {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 0 12px 8px;
transition: opacity .25s, transform .25s;
}
.ytp.controls-hidden .ytp-chrome-bottom { opacity: 0; transform: translateY(4px); pointer-events: none; }
.ytp.controls-hidden .ytp-gradient-bottom { opacity: 0; }
.ytp-progress-bar-container { padding: 4px 0; cursor: pointer; margin-bottom: 4px; }
.ytp-progress-bar {
position: relative; height: 3px;
background: rgba(255,255,255,.2); border-radius: 2px; transition: height .1s;
}
.ytp-progress-bar-container:hover .ytp-progress-bar,
.ytp-progress-bar.dragging { height: 5px; }
.ytp-play-progress {
position: absolute; top: 0; left: 0; bottom: 0;
background: #f00; border-radius: 2px; width: 0; pointer-events: none;
}
.ytp-scrubber-container {
position: absolute; top: 50%; transform: translateY(-50%);
width: 0; pointer-events: none;
}
.ytp-scrubber-button {
width: 13px; height: 13px; border-radius: 50%; background: #f00;
transform: translate(-50%, 0) scale(0); transition: transform .1s; margin-top: -4px;
}
.ytp-progress-bar-container:hover .ytp-scrubber-button,
.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, 0) scale(1); }
.ytp-hover-time {
position: absolute; bottom: 16px;
background: rgba(28,28,28,.9); color: #fff;
font-size: 12px; padding: 3px 6px; border-radius: 4px;
pointer-events: none; opacity: 0;
transform: translateX(-50%); white-space: nowrap; transition: opacity .1s;
}
.ytp-progress-bar-container:hover .ytp-hover-time { opacity: 1; }
.ytp-chrome-controls {
display: flex; align-items: center; justify-content: space-between; height: 36px;
}
.ytp-left-controls, .ytp-right-controls { display: flex; align-items: center; gap: 4px; }
.ytp-button {
background: none; border: none; color: #fff; cursor: pointer;
padding: 0; width: 36px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%; transition: background .15s; flex-shrink: 0;
}
.ytp-button:hover { background: rgba(255,255,255,.1); }
.ytp-button svg { width: 22px; height: 22px; fill: #fff; pointer-events: none; }
.ytp-button:focus { outline: none; }
.ytp-play-btn svg { width: 26px; height: 26px; }
.ytp-volume-area { display: flex; align-items: center; }
.ytp-volume-slider-wrap {
overflow: hidden; width: 0; transition: width .2s;
display: flex; align-items: center;
}
.ytp-volume-area:hover .ytp-volume-slider-wrap,
.ytp-volume-area:focus-within .ytp-volume-slider-wrap { width: 60px; }
.ytp-volume-range {
-webkit-appearance: none; appearance: none;
width: 52px; height: 3px; border-radius: 2px;
background: linear-gradient(to right, #fff var(--vol,50%), rgba(255,255,255,.3) var(--vol,50%));
outline: none; cursor: pointer; margin: 0 4px;
}
.ytp-volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; }
.ytp-volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; }
.ytp-time-display { font-size: 13px; color: #fff; white-space: nowrap; padding: 0 6px; line-height: 36px; }
.ytp-time-sep { opacity: .6; margin: 0 2px; }
.ytp-settings-wrap { position: relative; }
.ytp-settings-panel {
display: none; position: absolute; bottom: 44px; right: 0;
background: rgba(28,28,28,.95); border-radius: 12px;
min-width: 200px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,.6); z-index: 100;
}
.ytp-settings-panel.open { display: block; }
.ytp-settings-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; color: #fff; font-size: 13px;
cursor: pointer; transition: background .15s; white-space: nowrap;
}
.ytp-settings-item:hover { background: rgba(255,255,255,.1); }
.ytp-settings-item svg { width: 20px; height: 20px; fill: #fff; flex-shrink: 0; }
.ytp-settings-item .ytp-settings-val { margin-left: auto; color: rgba(255,255,255,.7); font-size: 12px; margin-right: 4px; }
.ytp-chevron { width: 18px; height: 18px; flex-shrink: 0; }
.ytp-speed-panel { display: none; }
.ytp-speed-panel.open { display: block; }
.ytp-speed-back {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.15);
}
.ytp-speed-back:hover { background: rgba(255,255,255,.1); }
.ytp-speed-back svg { width: 20px; height: 20px; fill: #fff; }
.ytp-speed-option {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px; color: #fff; font-size: 13px;
cursor: pointer; transition: background .15s;
}
.ytp-speed-option:hover { background: rgba(255,255,255,.1); }
.ytp-speed-check { width: 18px; height: 18px; fill: #fff; opacity: 0; flex-shrink: 0; }
.ytp-speed-option.active .ytp-speed-check { opacity: 1; }
.ytp-wrap.ytp-fullscreen {
position: fixed !important; inset: 0; z-index: 99999;
max-height: 100vh; height: 100vh; width: 100vw;
border-radius: 0; aspect-ratio: unset; margin: 0 !important;
}
.ytp-toggle-row { border-top: 1px solid rgba(255,255,255,.08); }
.ytp-toggle-row svg { opacity: .75; }
.ytp-toggle-val { font-size: 11px !important; opacity: .6; transition: color .15s, opacity .15s; }
.ytp-toggle-row.is-on .ytp-toggle-val { color: #f00; opacity: 1; }
.ytp-toggle-row.is-on svg { fill: #f00; opacity: 1; }
.ytp-toggle-switch {
width: 28px; height: 16px; background: rgba(255,255,255,.2);
border-radius: 8px; position: relative; flex-shrink: 0;
transition: background .2s; margin-left: 6px;
}
.ytp-toggle-row.is-on .ytp-toggle-switch { background: #e61e1e; }
.ytp-toggle-thumb {
position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; background: #fff;
border-radius: 50%; transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.ytp-toggle-row.is-on .ytp-toggle-thumb { transform: translateX(12px); }
@media (max-width: 576px) {
.ytp-wrap { border-radius: 0; max-height: 56vw; }
.ytp-button { width: 32px; height: 32px; }
.ytp-button svg { width: 18px; height: 18px; }
.ytp-time-display { font-size: 11px; padding: 0 4px; }
}
</style>
{{-- ══ JS ══ --}}
<script>
(function () {
const wrap = document.getElementById('ytpWrap');
const player = document.getElementById('audioContainer');
const audio = document.getElementById('audioEl');
const playBtn = document.getElementById('ytpPlayBtn');
const muteBtn = document.getElementById('ytpMuteBtn');
const volRange = document.getElementById('ytpVolume');
const timeCur = document.getElementById('ytpCurrent');
const timeDur = document.getElementById('ytpDuration');
const progCont = document.getElementById('ytpProgressContainer');
const progBar = document.getElementById('ytpProgressBar');
const played = document.getElementById('ytpPlayed');
const scrubber = document.getElementById('ytpScrubber');
const hoverTime = document.getElementById('ytpHoverTime');
const largePlay = document.getElementById('ytpLargePlay');
const settingsBtn = document.getElementById('ytpSettingsBtn');
const settingsPanel = document.getElementById('ytpSettingsPanel');
const speedRow = document.getElementById('ytpSpeedRow');
const speedPanel = document.getElementById('ytpSpeedPanel');
const speedBack = document.getElementById('ytpSpeedBack');
const speedLabel = document.getElementById('ytpSpeedLabel');
const speedOpts = document.querySelectorAll('.ytp-speed-option');
const fsBtn = document.getElementById('ytpFsBtn');
const loopRow = document.getElementById('ytpLoopRow');
const loopVal = document.getElementById('ytpLoopVal');
const barsBtn = document.getElementById('ytpBarsBtn');
const animCanvas = document.getElementById('audioAnimCanvas');
const NEXT_URL = @json($nextUrl ?? null);
let hideTimer = null;
let isDragging = false;
// ── Helpers ──────────────────────────────────────────────────
function fmt(s) {
if (!isFinite(s) || isNaN(s)) return '0:00';
s = Math.floor(s);
const m = Math.floor(s / 60), sec = s % 60;
return m + ':' + String(sec).padStart(2, '0');
}
function updatePlayIcon() {
playBtn.querySelector('.ytp-svg-play').style.display = audio.paused ? '' : 'none';
playBtn.querySelector('.ytp-svg-pause').style.display = audio.paused ? 'none' : '';
player.classList.toggle('playing', !audio.paused);
}
function updateVolumeIcon() {
const muted = audio.muted || audio.volume === 0;
muteBtn.querySelector('.ytp-svg-vol3').style.display = muted ? 'none' : '';
muteBtn.querySelector('.ytp-svg-vol0').style.display = muted ? '' : 'none';
const v = muted ? 0 : Math.round(audio.volume * 100);
volRange.value = v;
volRange.style.setProperty('--vol', v + '%');
}
function updateProgress() {
if (!audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
played.style.width = pct + '%';
scrubber.parentElement.style.left = pct + '%';
timeCur.textContent = fmt(audio.currentTime);
}
// ── Controls visibility ──────────────────────────────────────
function showControls() {
player.classList.remove('controls-hidden');
clearTimeout(hideTimer);
if (!audio.paused) hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000);
}
// ── Play / Pause ─────────────────────────────────────────────
function togglePlay() {
if (audio.paused) { audio.play(); } else { audio.pause(); }
}
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); showControls(); });
player.addEventListener('click', e => {
if (settingsPanel.contains(e.target) || settingsBtn === e.target) return;
togglePlay();
});
player.addEventListener('mousemove', showControls);
// ── Volume ───────────────────────────────────────────────────
muteBtn.addEventListener('click', e => {
e.stopPropagation();
audio.muted = !audio.muted;
if (!audio.muted && audio.volume === 0) audio.volume = 0.5;
updateVolumeIcon();
localStorage.setItem('ytpMuted', audio.muted ? '1' : '0');
});
volRange.addEventListener('input', e => {
e.stopPropagation();
const v = parseInt(e.target.value) / 100;
audio.volume = v;
audio.muted = v === 0;
updateVolumeIcon();
localStorage.setItem('ytpVolume', e.target.value);
});
volRange.addEventListener('click', e => e.stopPropagation());
// ── Progress bar ─────────────────────────────────────────────
function seekTo(pct) {
if (!audio.duration) return;
audio.currentTime = Math.max(0, Math.min(1, pct)) * audio.duration;
updateProgress();
}
function progressPct(clientX) {
const rect = progBar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
progCont.addEventListener('mousemove', e => {
hoverTime.textContent = fmt(progressPct(e.clientX) * (audio.duration || 0));
hoverTime.style.left = (progressPct(e.clientX) * 100) + '%';
if (isDragging) seekTo(progressPct(e.clientX));
});
progCont.addEventListener('mousedown', e => {
e.preventDefault(); isDragging = true;
progBar.classList.add('dragging'); seekTo(progressPct(e.clientX));
});
document.addEventListener('mousemove', e => { if (isDragging) seekTo(progressPct(e.clientX)); });
document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; progBar.classList.remove('dragging'); } });
progCont.addEventListener('touchstart', e => { isDragging = true; progBar.classList.add('dragging'); seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
progCont.addEventListener('touchmove', e => { if (isDragging) seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
progCont.addEventListener('touchend', () => { isDragging = false; progBar.classList.remove('dragging'); });
// ── Settings / Speed ─────────────────────────────────────────
settingsBtn.addEventListener('click', e => {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
clearTimeout(hideTimer);
});
document.addEventListener('click', e => {
if (!document.getElementById('ytpSettingsWrap').contains(e.target)) {
settingsPanel.classList.remove('open');
speedPanel.classList.remove('open');
speedRow.style.display = '';
}
});
speedRow.addEventListener('click', e => { e.stopPropagation(); speedRow.style.display = 'none'; speedPanel.classList.add('open'); });
speedBack.addEventListener('click', e => { e.stopPropagation(); speedPanel.classList.remove('open'); speedRow.style.display = ''; });
speedOpts.forEach(opt => {
opt.addEventListener('click', e => {
e.stopPropagation();
const s = parseFloat(opt.dataset.speed);
audio.playbackRate = s;
speedOpts.forEach(o => o.classList.remove('active'));
opt.classList.add('active');
speedLabel.textContent = s === 1 ? 'Normal' : s;
settingsPanel.classList.remove('open');
speedPanel.classList.remove('open');
speedRow.style.display = '';
});
});
// ── Fullscreen ───────────────────────────────────────────────
fsBtn.addEventListener('click', e => {
e.stopPropagation();
document.fullscreenElement ? document.exitFullscreen() : wrap.requestFullscreen();
showControls();
});
document.addEventListener('fullscreenchange', () => {
const fs = !!document.fullscreenElement;
wrap.classList.toggle('ytp-fullscreen', fs);
fsBtn.querySelector('.ytp-svg-fs-enter').style.display = fs ? 'none' : '';
fsBtn.querySelector('.ytp-svg-fs-exit').style.display = fs ? '' : 'none';
});
// ── Keyboard shortcuts ────────────────────────────────────────
document.addEventListener('keydown', e => {
const tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement.isContentEditable) return;
switch (e.key) {
case ' ': case 'k': case 'K': e.preventDefault(); togglePlay(); showControls(); break;
case 'm': case 'M':
e.preventDefault();
audio.muted = !audio.muted;
if (!audio.muted && audio.volume === 0) audio.volume = 0.5;
updateVolumeIcon(); showControls(); break;
case 'f': case 'F': e.preventDefault(); fsBtn.click(); break;
case 'ArrowLeft': e.preventDefault(); if (audio.duration) audio.currentTime = Math.max(0, audio.currentTime - 5); showControls(); break;
case 'ArrowRight': e.preventDefault(); if (audio.duration) audio.currentTime = Math.min(audio.duration, audio.currentTime + 5); showControls(); break;
case 'ArrowUp': e.preventDefault(); audio.volume = Math.min(1, audio.volume + 0.05); audio.muted = false; updateVolumeIcon(); showControls(); break;
case 'ArrowDown': e.preventDefault(); audio.volume = Math.max(0, audio.volume - 0.05); updateVolumeIcon(); showControls(); break;
default:
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
if (audio.duration) audio.currentTime = (parseInt(e.key) / 10) * audio.duration;
showControls();
}
}
});
// ── Audio events ─────────────────────────────────────────────
audio.addEventListener('play', () => { updatePlayIcon(); startBars(); showControls(); largePlay.classList.remove('visible'); requestWakeLock(); });
audio.addEventListener('pause', () => { updatePlayIcon(); stopBars(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock(); });
audio.addEventListener('timeupdate', updateProgress);
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); });
audio.addEventListener('ended', () => { releaseWakeLock(); if (NEXT_URL) window.location.href = NEXT_URL; });
audio.addEventListener('volumechange', updateVolumeIcon);
// ── Loop ─────────────────────────────────────────────────────
let isLooping = localStorage.getItem('ytpLoop') === '1';
audio.loop = isLooping;
function applyLoopState() {
if (!loopRow) return;
loopRow.classList.toggle('is-on', isLooping);
if (loopVal) loopVal.textContent = isLooping ? 'On' : 'Off';
}
applyLoopState();
if (loopRow) {
loopRow.addEventListener('click', e => {
e.stopPropagation();
isLooping = !isLooping;
audio.loop = isLooping;
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
applyLoopState();
});
}
// ── Wake Lock ─────────────────────────────────────────────────
let wakeLock = null;
async function requestWakeLock() {
if (!('wakeLock' in navigator) || wakeLock) return;
try { wakeLock = await navigator.wakeLock.request('screen'); } catch(e) {}
}
function releaseWakeLock() {
if (wakeLock) { wakeLock.release(); wakeLock = null; }
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !audio.paused) requestWakeLock();
});
// ── Bars visualiser ───────────────────────────────────────────
let audioCtx = null, analyser = null, dataArray = null, rafId = null;
let barsOn = localStorage.getItem('audioBarsOn') === '1';
// Extract dominant colors from cover art for bar gradients
let imgColors = ['rgb(255,255,255)', 'rgb(200,200,200)', 'rgb(170,170,170)'];
function colorWithAlpha(col, a) {
return col.replace('rgb(', 'rgba(').replace(')', ',' + a + ')');
}
function extractColors(img) {
try {
const cv = document.createElement('canvas');
cv.width = cv.height = 24;
const cx = cv.getContext('2d');
cx.drawImage(img, 0, 0, 24, 24);
const px = cx.getImageData(0, 0, 24, 24).data;
const buckets = {};
for (let i = 0; i < px.length; i += 4) {
const r = px[i], g = px[i+1], b = px[i+2];
const bright = (r + g + b) / 3;
if (bright < 25 || bright > 230) continue;
const max = Math.max(r,g,b), min = Math.min(r,g,b);
if (max === 0 || (max - min) / max < 0.25) continue;
const key = `${r>>2},${g>>2},${b>>2}`;
buckets[key] = (buckets[key] || { r:0, g:0, b:0, n:0 });
buckets[key].r += r; buckets[key].g += g; buckets[key].b += b; buckets[key].n++;
}
const sorted = Object.values(buckets).sort((a,b) => b.n - a.n);
if (!sorted.length) return;
const chosen = [sorted[0]];
for (let i = 1; i < sorted.length && chosen.length < 3; i++) {
const c = sorted[i];
const far = chosen.every(e => {
const dr = e.r/e.n - c.r/c.n, dg = e.g/e.n - c.g/c.n, db = e.b/e.n - c.b/c.n;
return Math.sqrt(dr*dr + dg*dg + db*db) > 60;
});
if (far) chosen.push(c);
}
imgColors = chosen.map(c => `rgb(${Math.round(c.r/c.n)},${Math.round(c.g/c.n)},${Math.round(c.b/c.n)})`);
while (imgColors.length < 3) imgColors.push(imgColors[0]);
} catch(e) {}
}
const coverImg = document.getElementById('audioCoverImg');
if (coverImg) {
if (coverImg.complete && coverImg.naturalWidth) {
extractColors(coverImg);
if (coverImg.naturalHeight > coverImg.naturalWidth) coverImg.style.objectFit = 'contain';
} else {
coverImg.addEventListener('load', () => {
extractColors(coverImg);
if (coverImg.naturalHeight > coverImg.naturalWidth) coverImg.style.objectFit = 'contain';
}, { once: true });
}
}
function applyBarsState() {
barsBtn.classList.toggle('bars-on', barsOn);
barsBtn.title = barsOn ? 'Visualiser: On' : 'Visualiser: Off';
animCanvas.style.display = barsOn ? 'block' : 'none';
}
applyBarsState();
barsBtn.addEventListener('click', e => {
e.stopPropagation();
barsOn = !barsOn;
localStorage.setItem('audioBarsOn', barsOn ? '1' : '0');
applyBarsState();
if (barsOn && !audio.paused) startBars(); else stopBars();
showControls();
});
function initAnalyser() {
if (audioCtx) { audioCtx.resume(); return; }
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const src = audioCtx.createMediaElementSource(audio);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.75;
src.connect(analyser);
analyser.connect(audioCtx.destination);
dataArray = new Uint8Array(analyser.frequencyBinCount);
} catch (e) { console.warn('Web Audio unavailable:', e); }
}
function drawBars() {
if (!analyser) return;
analyser.getByteFrequencyData(dataArray);
const ctx = animCanvas.getContext('2d');
const w = animCanvas.offsetWidth;
const h = animCanvas.offsetHeight;
if (animCanvas.width !== w) animCanvas.width = w;
if (animCanvas.height !== h) animCanvas.height = h;
ctx.clearRect(0, 0, w, h);
const bins = 48;
const barW = (w / bins) * 0.7;
const gap = (w / bins) * 0.3;
const maxH = h * 0.25;
for (let i = 0; i < bins; i++) {
const val = dataArray[i + 2] / 255;
const barH = Math.max(3, val * maxH);
const x = i * (barW + gap) + gap / 2;
const y = h - barH;
const col = imgColors[Math.floor((i / bins) * imgColors.length)];
const grad = ctx.createLinearGradient(0, y, 0, h);
grad.addColorStop(0, colorWithAlpha(col, (0.5 + val * 0.5).toFixed(2)));
grad.addColorStop(1, colorWithAlpha(col, 0.12));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(x, y, barW, barH, [3, 3, 0, 0]);
ctx.fill();
}
}
function barsLoop() {
rafId = requestAnimationFrame(barsLoop);
drawBars();
}
function startBars() {
if (!barsOn) return;
initAnalyser();
if (!rafId) rafId = requestAnimationFrame(barsLoop);
}
function stopBars() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (animCanvas.getContext) animCanvas.getContext('2d').clearRect(0, 0, animCanvas.width, animCanvas.height);
}
// ── Crossfade slideshow ───────────────────────────────────────
const SLIDE_URLS = @json($slideUrls);
if (SLIDE_URLS.length > 1) {
const slideA = document.getElementById('slideA');
const slideB = document.getElementById('slideB');
let currentSlide = 0;
let aIsTop = true; // slideA starts on top (opacity 1)
// Preload all images and detect portrait orientation
const slideOrientations = new Array(SLIDE_URLS.length).fill(false); // true = portrait
SLIDE_URLS.forEach((url, idx) => {
const img = new Image();
img.onload = () => { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
img.src = url;
});
function applyOrientation(el, idx) {
el.classList.toggle('portrait', !!slideOrientations[idx]);
}
function getSlideInterval() {
return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000;
}
function advanceSlide() {
currentSlide = (currentSlide + 1) % SLIDE_URLS.length;
const next = SLIDE_URLS[currentSlide];
if (aIsTop) {
slideB.src = next;
applyOrientation(slideB, currentSlide);
slideB.style.zIndex = '2';
slideB.style.opacity = '1';
slideA.style.opacity = '0';
slideA.style.zIndex = '1';
} else {
slideA.src = next;
applyOrientation(slideA, currentSlide);
slideA.style.zIndex = '2';
slideA.style.opacity = '1';
slideB.style.opacity = '0';
slideB.style.zIndex = '1';
}
aIsTop = !aIsTop;
}
let slideshowTimer = null;
function startSlideshow() {
if (slideshowTimer || SLIDE_URLS.length <= 1) return;
slideshowTimer = setInterval(advanceSlide, getSlideInterval());
}
function stopSlideshow() {
clearInterval(slideshowTimer);
slideshowTimer = null;
}
audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow);
audio.addEventListener('ended', stopSlideshow);
// Seek: jump to the correct slide instantly (no transition flash)
audio.addEventListener('seeked', () => {
if (!audio.duration) return;
stopSlideshow();
const idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length),
SLIDE_URLS.length - 1
);
currentSlide = idx;
// Reset: show correct slide without animation
slideA.style.transition = 'none';
slideB.style.transition = 'none';
slideA.src = SLIDE_URLS[idx];
applyOrientation(slideA, idx);
slideA.style.opacity = '1'; slideA.style.zIndex = '2';
slideB.style.opacity = '0'; slideB.style.zIndex = '1';
const nextIdx = (idx + 1) % SLIDE_URLS.length;
slideB.src = SLIDE_URLS[nextIdx];
applyOrientation(slideB, nextIdx);
aIsTop = true;
requestAnimationFrame(() => {
slideA.style.transition = '';
slideB.style.transition = '';
});
if (!audio.paused) startSlideshow();
});
// Extract colors + apply orientation for first slide
function initSlideA() {
extractColors(slideA);
const portrait = slideA.naturalHeight > slideA.naturalWidth;
slideA.classList.toggle('portrait', portrait);
slideOrientations[0] = portrait;
}
if (slideA.complete && slideA.naturalWidth) {
initSlideA();
} else {
slideA.addEventListener('load', initSlideA, { once: true });
}
}
// ── Init ─────────────────────────────────────────────────────
const savedVol = localStorage.getItem('ytpVolume');
const savedMuted = localStorage.getItem('ytpMuted');
audio.volume = savedVol ? parseInt(savedVol) / 100 : 0.8;
audio.muted = true; // start muted so autoplay always works
updateVolumeIcon();
largePlay.classList.add('visible');
showControls();
// Once playing, restore user's mute preference
audio.addEventListener('playing', function restoreSound() {
audio.removeEventListener('playing', restoreSound);
if (savedMuted !== '1') {
audio.muted = false;
updateVolumeIcon();
}
}, { once: true });
audio.addEventListener('loadedmetadata', () => {
timeDur.textContent = fmt(audio.duration);
const p = audio.play();
if (p) p.catch(() => {
audio.muted = true;
audio.play().catch(() => {});
});
});
})();
</script>
@@ -58,7 +58,7 @@
rows="2" style="margin-bottom: 8px;"></textarea>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="_comment-btn _comment-btn-primary"
onclick="_comment.submitReply({{ $video->id ?? $comment->video_id }}, {{ $comment->id }})">
onclick="_comment.submitReply('{{ isset($video) ? $video->getRouteKey() : \App\Models\Video::encodeId($comment->video_id) }}', {{ $comment->id }})">
<i class="bi bi-chat-dots"></i>
<span>Send</span>
</button>
@@ -0,0 +1,85 @@
{{--
Tabbed description + insights box.
Expects: $video (Video model)
Optionally accepts: $descriptionSlot — raw HTML to show in the About tab body
--}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$hasDesc = !empty($video->description) || isset($descriptionSlot);
$showBox = $hasDesc || $isVideoOwner;
$fullDescription = $video->description ?? '';
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
@if ($showBox)
<style>
/* ── Description box ─────────────────────────────────── */
.vdb-wrap { background:var(--bg-secondary); border-radius:12px; margin-top:12px; overflow:hidden; border:1px solid var(--border-color); }
.vdb-tabs { display:flex; border-bottom:1px solid var(--border-color); padding:0 4px; }
.vdb-tab { background:none; border:none; color:var(--text-secondary); font-size:13px; font-weight:600; padding:0 16px; height:44px; cursor:pointer; position:relative; transition:color .15s; white-space:nowrap; display:flex; align-items:center; gap:6px; }
.vdb-tab:hover { color:var(--text-primary); }
.vdb-tab.active { color:var(--text-primary); }
.vdb-tab.active::after { content:''; position:absolute; bottom:0; left:0; right:0; height:2px; background:#ef4444; border-radius:2px 2px 0 0; }
.vdb-panel { display:none; padding:14px 16px 16px; }
.vdb-panel.active { display:block; }
.vdb-meta { font-size:13px; font-weight:600; color:var(--text-secondary); margin-bottom:10px; display:flex; gap:10px; flex-wrap:wrap; }
.vdb-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); white-space:pre-wrap; }
.vdb-desc-text p { margin-bottom:8px; }
.vdb-desc-text a { color:#3ea6ff; }
.vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; }
</style>
<div class="vdb-wrap" id="vdbWrap">
<div class="vdb-tabs">
<button class="vdb-tab active" data-panel="vdb-about" onclick="switchVdbTab('vdb-about',this)">
<i class="bi bi-card-text"></i> About
</button>
@if($isVideoOwner)
<button class="vdb-tab" data-panel="vdb-insights" onclick="switchVdbTab('vdb-insights',this)">
<i class="bi bi-bar-chart-line-fill"></i> Insights
</button>
@endif
</div>
<div class="vdb-panel active" id="vdb-about">
<div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
@if($video->duration)<span>•</span><span><i class="bi bi-clock" style="margin-right:4px;"></i>{{ $video->formatted_duration }}</span>@endif
</div>
@if(isset($descriptionSlot))
{!! $descriptionSlot !!}
@elseif($hasDesc)
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
@if($needsExpand)
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
@endif
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
</div>
@if($isVideoOwner)
<x-video-insights :video="$video" />
@endif
</div>
<script>
// ── Tab switching ──────────────────────────────────────
function switchVdbTab(panelId, btn) {
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(panelId).classList.add('active');
if (panelId === 'vdb-insights' && !window._insLoaded) loadInsights();
}
function toggleVdbDesc(btn) {
const s = document.getElementById('vdbDescShort'), f = document.getElementById('vdbDescFull');
if (!f) return;
if (f.style.display === 'none') { s.style.display='none'; f.style.display='block'; btn.textContent='Show less'; }
else { s.style.display='block'; f.style.display='none'; btn.textContent='Show more'; }
}
</script>
@endif
@@ -17,7 +17,7 @@
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 6px 0; flex-wrap: wrap; gap: 12px;">
{{-- Left: Channel Info --}}
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
@else
@@ -47,30 +47,30 @@
<div class="video-actions">
@auth
{{-- Like Button with Icon and Count --}}
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video) : route('videos.like', $video) }}" class="d-inline">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<button type="submit" class="action-btn {{ $video->isLikedBy(Auth::user()) ? 'action-btn-primary' : '' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
<span>{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
</form>
{{-- Edit Button - Only for video owner --}}
@if(Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
<i class="bi bi-pencil"></i> Edit
<button class="action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> <span>Edit</span>
</button>
@endif
@else
<a href="{{ route('login') }}" class="yt-action-btn">
<i class="bi bi-hand-thumbs-up"></i> Like
<a href="{{ route('login') }}" class="action-btn">
<i class="bi bi-hand-thumbs-up"></i> <span>Like</span>
</a>
@endauth
{{-- Share Button --}}
@if($video->isShareable())
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-share"></i> Share
<button class="action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-share"></i> <span>Share</span>
</button>
@endif
</div>
@@ -190,8 +190,8 @@ function toggleDescription() {
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
></textarea>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
<button type="button" class="action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
<button type="button" class="action-btn action-btn-primary" onclick="submitComment('{{ $video->getRouteKey() }}')">Comment</button>
</div>
</div>
</div>
@@ -243,20 +243,20 @@ function loadComments(videoId) {
}
function deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment?')) return;
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
}
});
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
}
});
}, 'Delete');
}
// Highlight @mentions in comments
+551 -331
View File
@@ -2,349 +2,569 @@
@section('title', 'Shorts - ' . config('app.name'))
@section('main_class', 'shorts-view-page')
@section('extra_styles')
<style>
/* ── Make yt-main a scroll-snap container ── */
.yt-main.shorts-view-page {
padding: 0 !important;
scroll-snap-type: y mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
.yt-main.shorts-view-page::-webkit-scrollbar { display: none; }
/* ── Each short occupies the full visible height ── */
.short-item {
height: calc(100vh - 56px); /* desktop: minus header */
scroll-snap-align: start;
scroll-snap-stop: always;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
position: relative;
}
@media (max-width: 768px) {
.short-item {
height: calc(100vh - 112px); /* mobile: minus header + bottom nav */
gap: 0;
}
}
/* ── Video wrapper — 9:16 centered ── */
.short-player-wrap {
position: relative;
height: 92%;
max-height: 780px;
aspect-ratio: 9 / 16;
border-radius: 14px;
overflow: hidden;
background: #000;
flex-shrink: 0;
cursor: pointer;
}
@media (max-width: 768px) {
.short-player-wrap {
height: 100%;
max-height: unset;
width: 100%;
aspect-ratio: unset;
border-radius: 0;
}
}
.short-player-wrap video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ── Tap-to-pause icon ── */
.short-pause-icon {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0;
transition: opacity .2s;
}
.short-pause-icon i {
font-size: 64px;
color: rgba(255,255,255,.85);
filter: drop-shadow(0 2px 8px rgba(0,0,0,.5));
}
.short-player-wrap.paused .short-pause-icon { opacity: 1; }
/* ── Info overlay at the bottom ── */
.short-info-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 48px 14px 16px;
background: linear-gradient(transparent, rgba(0,0,0,.72));
pointer-events: none;
}
.short-channel-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.short-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1.5px solid rgba(255,255,255,.6);
flex-shrink: 0;
}
.short-avatar-initial {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e61e1e;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.short-channel-name {
font-size: 13px;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,.5);
}
.short-title {
font-size: 14px;
font-weight: 500;
color: #fff;
margin: 0;
text-shadow: 0 1px 3px rgba(0,0,0,.5);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.short-views {
font-size: 12px;
color: rgba(255,255,255,.75);
margin-top: 4px;
}
/* ── Right-side action buttons ── */
.short-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.short-actions {
position: absolute;
right: 10px;
bottom: 80px;
gap: 16px;
}
}
.short-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: none;
color: inherit;
}
.short-action-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255,255,255,.12);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
transition: background .2s, transform .15s;
backdrop-filter: blur(6px);
}
@media (min-width: 769px) {
.short-action-icon {
background: var(--bg-secondary);
color: var(--text-primary);
}
}
.short-action-btn:hover .short-action-icon,
.short-action-btn:active .short-action-icon {
background: rgba(255,255,255,.25);
transform: scale(1.08);
}
@media (min-width: 769px) {
.short-action-btn:hover .short-action-icon {
background: var(--border-color);
}
}
.short-action-label {
font-size: 12px;
font-weight: 500;
color: #fff;
}
@media (min-width: 769px) {
.short-action-label { color: var(--text-secondary); }
}
.short-action-btn.liked .short-action-icon { background: rgba(230,30,30,.3); color: #e61e1e; }
/* ── Up / Down nav arrows (desktop only) ── */
.short-nav-arrows {
position: absolute;
right: calc(50% - 230px - 80px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 900px) { .short-nav-arrows { display: none; } }
.short-nav-arrow {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background .2s;
}
.short-nav-arrow:hover { background: var(--border-color); }
/* ── Progress bar ── */
.short-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255,255,255,.3);
pointer-events: none;
}
.short-progress-fill {
height: 100%;
background: #fff;
width: 0%;
transition: width .1s linear;
}
/* ── Mute button ── */
.short-mute-btn {
position: absolute;
top: 14px;
right: 14px;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0,0,0,.45);
border: none;
color: #fff;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
backdrop-filter: blur(4px);
transition: background .2s;
}
.short-mute-btn:hover { background: rgba(0,0,0,.7); }
/* ── Empty state ── */
.shorts-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
color: var(--text-secondary);
text-align: center;
padding: 32px;
}
.shorts-empty i { font-size: 64px; color: #ff0050; }
.shorts-empty h2 { font-size: 22px; font-weight: 600; color: var(--text-primary); margin: 0; }
.shorts-empty p { margin: 0; }
</style>
@endsection
@section('content')
<div class="yt-main">
<div class="yt-content">
<!-- Shorts Header -->
<div class="shorts-header">
<div class="shorts-brand">
<div class="shorts-logo-icon">
<i class="bi bi-play-fill"></i>
</div>
<h1>Shorts</h1>
</div>
</div>
<!-- Shorts Grid - YouTube Style -->
<div class="shorts-container">
@forelse($videos as $video)
<div class="shorts-card">
<a href="{{ route('videos.show', $video->id) }}" class="shorts-link">
<div class="shorts-thumbnail-wrapper">
<img src="{{ $video->thumbnail_url }}" alt="{{ $video->title }}" class="shorts-thumb">
<div class="shorts-overlay">
<span class="shorts-views">
<i class="bi bi-play-fill"></i> {{ number_format($video->view_count) }}
</span>
</div>
@if ($video->duration)
<span class="shorts-time">{{ gmdate('i:s', $video->duration) }}</span>
@endif
</div>
<div class="shorts-details">
<h3 class="shorts-title">{{ $video->title }}</h3>
<div class="shorts-channel-info">
@if ($video->user)
<div class="shorts-channel-row">
@if ($video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" class="shorts-avatar"
alt="{{ $video->user->name }}">
@else
<div class="shorts-avatar-placeholder">
{{ substr($video->user->name, 0, 1) }}</div>
@endif
<span class="shorts-channel-name">{{ $video->user->name }}</span>
</div>
@endif
<span class="shorts-ago">{{ $video->created_at->diffForHumans() }}</span>
</div>
</div>
</a>
</div>
@empty
<div class="shorts-empty-state">
<div class="shorts-empty-icon">
<i class="bi bi-collection-play"></i>
</div>
<h2>No Shorts yet</h2>
<p>Be the first to upload a Short!</p>
@auth
<a href="{{ route('videos.create') }}" class="shorts-upload-btn">
<i class="bi bi-plus-lg"></i> Upload Short
</a>
@else
<a href="{{ route('login') }}" class="shorts-upload-btn">
<i class="bi bi-box-arrow-in-right"></i> Login to Upload
</a>
@endauth
</div>
@endforelse
@forelse($videos as $video)
@php
$src = asset('storage/videos/' . $video->filename);
$thumb = $video->thumbnail_url;
$user = $video->user;
$index = $loop->index;
@endphp
<div class="short-item" data-id="{{ $video->id }}" data-index="{{ $index }}">
{{-- Up/Down nav (desktop) --}}
<div class="short-nav-arrows">
@if(!$loop->first)
<button class="short-nav-arrow" onclick="navigateShort(-1, {{ $index }})">
<i class="bi bi-chevron-up"></i>
</button>
@endif
@if(!$loop->last)
<button class="short-nav-arrow" onclick="navigateShort(1, {{ $index }})">
<i class="bi bi-chevron-down"></i>
</button>
@endif
</div>
{{-- Video player --}}
<div class="short-player-wrap" id="wrap-{{ $index }}" onclick="toggleShortPlay({{ $index }})">
<video
id="sv-{{ $index }}"
src="{{ $src }}"
poster="{{ $thumb }}"
loop
playsinline
preload="{{ $loop->first ? 'auto' : 'none' }}"
style="width:100%;height:100%;object-fit:cover;">
</video>
{{-- Pause icon --}}
<div class="short-pause-icon"><i class="bi bi-pause-fill"></i></div>
{{-- Info overlay --}}
<div class="short-info-overlay">
<div class="short-channel-row">
@if($user && $user->avatar_url)
<img src="{{ $user->avatar_url }}" class="short-avatar" alt="{{ $user->name }}">
@elseif($user)
<div class="short-avatar-initial">{{ substr($user->name, 0, 1) }}</div>
@endif
<span class="short-channel-name">{{ $user->name ?? '' }}</span>
</div>
<p class="short-title">{{ $video->title }}</p>
<div class="short-views"><i class="bi bi-play-fill"></i> {{ number_format($video->view_count) }} views</div>
</div>
{{-- Mute button --}}
<button class="short-mute-btn" id="mute-{{ $index }}" onclick="event.stopPropagation(); toggleMute({{ $index }})">
<i class="bi bi-volume-up-fill" id="mute-icon-{{ $index }}"></i>
</button>
{{-- Progress bar --}}
<div class="short-progress">
<div class="short-progress-fill" id="prog-{{ $index }}"></div>
</div>
</div>
<style>
/* Shorts Page - YouTube Shorts Style */
.shorts-header {
margin-bottom: 20px;
padding: 12px 0;
}
{{-- Action buttons --}}
<div class="short-actions">
{{-- Like --}}
@php $vKey = $video->getRouteKey(); @endphp
<button class="short-action-btn" id="like-btn-{{ $vKey }}" onclick="shortLike('{{ $vKey }}')">
<div class="short-action-icon"><i class="bi bi-heart{{ $video->isLikedBy(auth()->user()) ? '-fill' : '' }}" id="like-icon-{{ $vKey }}"></i></div>
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
</button>
.shorts-brand {
display: flex;
align-items: center;
gap: 12px;
}
{{-- Comments --}}
<a class="short-action-btn" href="{{ route('videos.show', $video) }}#comments">
<div class="short-action-icon"><i class="bi bi-chat-fill"></i></div>
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
</a>
.shorts-logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #ff0050, #ff6b6b);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
}
{{-- Share --}}
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
<div class="short-action-icon"><i class="bi bi-share-fill"></i></div>
<span class="short-action-label">Share</span>
</button>
.shorts-brand h1 {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
{{-- Open full page --}}
<a class="short-action-btn" href="{{ route('videos.show', $video) }}">
<div class="short-action-icon"><i class="bi bi-box-arrow-up-right"></i></div>
<span class="short-action-label">Open</span>
</a>
</div>
/* Shorts Grid - 4 columns on desktop */
.shorts-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
</div>
@empty
/* Shorts Card */
.shorts-card {
background: transparent;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s;
}
<div class="shorts-empty">
<i class="bi bi-collection-play"></i>
<h2>No Shorts yet</h2>
<p>Shorts are vertical videos under 60 seconds.</p>
@auth
<a href="{{ route('videos.create') }}" class="action-btn action-btn-primary">
<i class="bi bi-plus-lg"></i> <span>Upload Short</span>
</a>
@else
<a href="{{ route('login') }}" class="action-btn action-btn-primary">
<i class="bi bi-box-arrow-in-right"></i> <span>Login to Upload</span>
</a>
@endauth
</div>
.shorts-card:hover {
transform: scale(1.02);
}
@endforelse
.shorts-link {
text-decoration: none;
color: inherit;
display: block;
}
.shorts-thumbnail-wrapper {
position: relative;
aspect-ratio: 9/16;
border-radius: 12px;
overflow: hidden;
background: #1a1a1a;
}
.shorts-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.shorts-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0;
transition: opacity 0.2s;
}
.shorts-card:hover .shorts-overlay {
opacity: 1;
}
.shorts-views {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: white;
}
.shorts-time {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.shorts-details {
padding: 10px 4px;
}
.shorts-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.shorts-channel-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.shorts-channel-row {
display: flex;
align-items: center;
gap: 8px;
}
.shorts-avatar,
.shorts-avatar-placeholder {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.shorts-avatar-placeholder {
background: #666;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
}
.shorts-channel-name {
font-size: 12px;
color: var(--text-secondary);
}
.shorts-ago {
font-size: 11px;
color: var(--text-secondary);
opacity: 0.7;
}
/* Empty State */
.shorts-empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
background: var(--bg-secondary);
border-radius: 16px;
}
.shorts-empty-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: linear-gradient(135deg, #ff0050, #ff6b6b);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.shorts-empty-icon i {
font-size: 36px;
color: white;
}
.shorts-empty-state h2 {
font-size: 20px;
color: var(--text-primary);
margin: 0 0 8px;
}
.shorts-empty-state p {
color: var(--text-secondary);
margin: 0 0 20px;
}
.shorts-upload-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #ff0050;
color: white;
border-radius: 20px;
text-decoration: none;
font-weight: 500;
font-size: 14px;
transition: background 0.2s;
}
.shorts-upload-btn:hover {
background: #e60048;
}
/* Pagination */
.shorts-pagination {
margin-top: 30px;
display: flex;
justify-content: center;
}
.shorts-pagination .pagination {
gap: 4px;
}
.shorts-pagination .page-link {
background: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
font-size: 13px;
}
.shorts-pagination .page-item.active .page-link {
background: #ff0050;
border-color: #ff0050;
}
/* Responsive */
@media (max-width: 1200px) {
.shorts-container {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.shorts-container {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
@media (max-width: 600px) {
.shorts-container {
grid-template-columns: 1fr;
max-width: 320px;
margin: 0 auto;
}
.shorts-header {
text-align: center;
}
.shorts-brand {
justify-content: center;
}
}
</style>
@endsection
@section('scripts')
<script>
(function () {
const main = document.getElementById('main');
let muted = false;
let current = -1;
// ── IntersectionObserver — auto-play visible short ─────────
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const item = entry.target;
const idx = parseInt(item.dataset.index);
const video = document.getElementById('sv-' + idx);
if (!video) return;
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
if (current !== -1 && current !== idx) pauseShort(current);
current = idx;
video.muted = muted;
video.volume = 1;
video.play().catch(() => {
// autoplay blocked — start muted then unmute
video.muted = true;
muted = true;
updateMuteIcon(idx);
video.play().catch(() => {});
});
document.getElementById('wrap-' + idx).classList.remove('paused');
} else {
pauseShort(idx);
}
});
}, { root: main, threshold: 0.6 });
document.querySelectorAll('.short-item').forEach(el => observer.observe(el));
// ── Play / pause on tap ────────────────────────────────────
window.toggleShortPlay = function(idx) {
const video = document.getElementById('sv-' + idx);
const wrap = document.getElementById('wrap-' + idx);
if (!video) return;
if (video.paused) {
video.play().catch(() => {});
wrap.classList.remove('paused');
} else {
video.pause();
wrap.classList.add('paused');
}
};
function pauseShort(idx) {
const video = document.getElementById('sv-' + idx);
const wrap = document.getElementById('wrap-' + idx);
if (video) { video.pause(); video.currentTime = 0; }
if (wrap) wrap.classList.remove('paused');
const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = '0%';
}
// ── Progress bars ──────────────────────────────────────────
document.querySelectorAll('.short-item video').forEach(video => {
const idx = video.id.replace('sv-', '');
video.addEventListener('timeupdate', () => {
if (!video.duration) return;
const prog = document.getElementById('prog-' + idx);
if (prog) prog.style.width = ((video.currentTime / video.duration) * 100) + '%';
});
});
// ── Mute toggle ────────────────────────────────────────────
window.toggleMute = function(idx) {
muted = !muted;
const video = document.getElementById('sv-' + idx);
if (video) video.muted = muted;
updateMuteIcon(idx);
};
function updateMuteIcon(idx) {
const icon = document.getElementById('mute-icon-' + idx);
if (icon) icon.className = muted ? 'bi bi-volume-mute-fill' : 'bi bi-volume-up-fill';
}
// ── Desktop nav arrows ─────────────────────────────────────
window.navigateShort = function(dir, fromIndex) {
const target = document.querySelector('.short-item[data-index="' + (fromIndex + dir) + '"]');
if (target) target.scrollIntoView({ behavior: 'smooth' });
};
// ── Keyboard nav ───────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const dir = e.key === 'ArrowDown' ? 1 : -1;
if (current !== -1) navigateShort(dir, current);
}
if (e.key === 'm' || e.key === 'M') {
if (current !== -1) toggleMute(current);
}
});
// ── Like ───────────────────────────────────────────────────
window.shortLike = function(videoId) {
@auth
fetch('/videos/' + videoId + '/toggle-like', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
const btn = document.getElementById('like-btn-' + videoId);
const icon = document.getElementById('like-icon-' + videoId);
const count = document.getElementById('like-count-' + videoId);
if (data.liked !== undefined) {
icon.className = data.liked ? 'bi bi-heart-fill' : 'bi bi-heart';
btn.classList.toggle('liked', data.liked);
}
if (data.like_count !== undefined) count.textContent = Number(data.like_count).toLocaleString();
})
.catch(() => {});
@else
window.location.href = '{{ route("login") }}';
@endauth
};
// ── Share ──────────────────────────────────────────────────
window.shortShare = function(videoKey, title) {
const url = window.location.origin + '/videos/' + videoKey;
if (navigator.share) {
navigator.share({ title: title, url: url }).catch(() => {});
} else if (typeof openShareModal === 'function') {
openShareModal(url, title);
} else {
navigator.clipboard.writeText(url).then(() => {
if (typeof showToast === 'function') showToast('Link copied!', 'success');
}).catch(() => {});
}
};
})();
</script>
@endsection
+180 -82
View File
@@ -266,6 +266,15 @@
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
border-radius: 8px;
padding: 6px;
transition: background .15s;
}
.sidebar-video-card:hover { background: var(--bg-secondary); }
.sidebar-video-card.current-video {
background: rgba(230,30,30,.10);
border-left: 3px solid var(--brand-red);
padding-left: 9px;
}
.sidebar-thumb {
@@ -430,7 +439,7 @@
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
<source src="{{ route('videos.stream', $video) }}" type="video/mp4">
</video>
</div>
@@ -459,7 +468,7 @@
@auth
<!-- Like Button -->
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video) : route('videos.like', $video) }}"
class="d-inline">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
@@ -471,7 +480,7 @@
<!-- Edit Button - Only for video owner -->
@if (Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
<button class="yt-action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</button>
@endif
@@ -482,7 +491,7 @@
@endauth
@if ($video->isShareable())
<button class="yt-action-btn"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"><i
onclick="videoShare('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')"><i
class="bi bi-share"></i> Share</button>
@endif
@@ -492,7 +501,7 @@
</button>
@auth
<!-- Quick Watch Later Button -->
<form method="POST" action="{{ route('videos.watchLater', $video->id) }}" class="d-inline"
<form method="POST" action="{{ route('videos.watchLater', $video) }}" class="d-inline"
style="display: inline;">
@csrf
<button type="submit" class="yt-action-btn" title="Watch Later">
@@ -507,7 +516,7 @@
<div class="channel-row"
style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none"
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none"
style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if ($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar"
@@ -533,72 +542,136 @@
</div>
</div>
<!-- Description -->
@if ($video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
{{-- ── Description / Insights box ── --}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$hasDesc = !empty($video->description);
$showBox = $hasDesc || $isVideoOwner;
@endphp
@if ($showBox)
@php
$fullDescription = $video->description ?? '';
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
<div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-top: 12px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content">
@if ($needsExpand)
<div class="description-short">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span style="color: var(--text-secondary);">... </span>
</div>
<div class="description-full" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div>
<button onclick="toggleDescription()"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">Show
more</button>
@else
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
@endif
</div>
<style>
/* ── Description box ── */
.vdb-wrap {
background: var(--bg-secondary);
border-radius: 12px;
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.vdb-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
padding: 0 4px;
}
.vdb-tab {
background: none; border: none;
color: var(--text-secondary);
font-size: 13px; font-weight: 600;
padding: 0 16px; height: 44px;
cursor: pointer; position: relative;
transition: color .15s; white-space: nowrap;
display: flex; align-items: center; gap: 6px;
}
.vdb-tab:hover { color: var(--text-primary); }
.vdb-tab.active { color: var(--text-primary); }
.vdb-tab.active::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0;
height: 2px; background: #ef4444; border-radius: 2px 2px 0 0;
}
.vdb-panel { display: none; padding: 14px 16px 16px; }
.vdb-panel.active { display: block; }
/* About panel */
.vdb-meta { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; display: flex; gap: 10px; flex-wrap: wrap; }
.vdb-desc-text { font-size: 14px; line-height: 1.6; color: var(--text-primary); white-space: pre-wrap; }
.vdb-desc-text p { margin-bottom: 8px; }
.vdb-desc-text a { color: #3ea6ff; }
.vdb-show-more { background: none; border: none; color: var(--text-primary); font-weight: 700; font-size: 13px; cursor: pointer; padding: 6px 0 0; }
</style>
<div class="vdb-wrap" id="vdbWrap">
{{-- Tab bar --}}
<div class="vdb-tabs">
<button class="vdb-tab active" data-panel="vdb-about" onclick="switchVdbTab('vdb-about',this)">
<i class="bi bi-card-text"></i> About
</button>
@if($isVideoOwner)
<button class="vdb-tab" data-panel="vdb-insights" onclick="switchVdbTab('vdb-insights',this)" id="insightsTabBtn">
<i class="bi bi-bar-chart-line-fill"></i> Insights
</button>
@endif
</div>
<script>
function toggleDescription() {
const short = document.querySelector('.description-short');
const full = document.querySelector('.description-full');
const btn = document.querySelector('.video-description-box button');
if (short.style.display !== 'none') {
short.style.display = 'none';
full.style.display = 'block';
btn.textContent = 'Show less';
} else {
short.style.display = 'block';
full.style.display = 'none';
btn.textContent = 'Show more';
}
}
</script>
{{-- About panel --}}
<div class="vdb-panel active" id="vdb-about">
<div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
@if($video->duration)
<span>•</span>
<span><i class="bi bi-clock" style="margin-right:4px;"></i>{{ $video->formatted_duration }}</span>
@endif
</div>
@if($hasDesc)
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
@if($needsExpand)
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
@endif
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
</div>
<style>
.video-description-box .description-text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
}
{{-- Insights panel (owner only) --}}
@if($isVideoOwner)
<x-video-insights :video="$video" />
@endif
</div>
.video-description-box .description-text p {
margin-bottom: 8px;
}
<script>
// ── Tab switching ──────────────────────────────────────
function switchVdbTab(panelId, btn) {
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(panelId).classList.add('active');
if (panelId === 'vdb-insights' && !window._insLoaded) loadInsights();
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style>
// ── Description expand/collapse ───────────────────────
function toggleVdbDesc(btn) {
const short = document.getElementById('vdbDescShort');
const full = document.getElementById('vdbDescFull');
if (full.style.display === 'none') {
short.style.display = 'none';
full.style.display = 'block';
btn.textContent = 'Show less';
} else {
short.style.display = 'block';
full.style.display = 'none';
btn.textContent = 'Show more';
}
}
// ── Share + track ──────────────────────────────────────
function videoShare(url, title, trackUrl) {
openShareModal(url, title);
fetch(trackUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', 'Accept': 'application/json' }
}).catch(() => {});
}
</script>
@endif
@include('components.video-comments', ['video' => $video])
@@ -617,9 +690,10 @@
</h3>
<div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
@php $isCurrent = $playlistVideo->id === $video->id; @endphp
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
{{ $isCurrent ? '' : "onclick=\"window.location.href='".route('videos.show', $playlistVideo)."?playlist={$playlist->share_token}'\"" }}
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -650,13 +724,16 @@
{{ Str::limit($playlistVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($playlistVideo->view_count) }} views •
{{ $playlistVideo->created_at->diffForHumans() }}</div>
@if($isCurrent)
<div style="color:var(--brand-red);font-weight:600;font-size:11px;">▶ Now Playing</div>
@else
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($playlistVideo->view_count) }} views •
{{ $playlistVideo->created_at->diffForHumans() }}</div>
@endif
</div>
</div>
</div>
@endif
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
@@ -671,8 +748,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -718,7 +795,7 @@
<div class="mobile-bottom-bar">
@auth
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video) : route('videos.like', $video) }}"
class="d-inline" style="flex:1;">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
@@ -766,12 +843,22 @@
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ $video->id }});
openEditVideoModal('{{ $video->getRouteKey() }}');
});
</script>
@endauth
@endif
@if(session('_auto_dl_video') && $video->isAudioOnly())
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof startSlideshowDownload === 'function') {
startSlideshowDownload('{{ $video->getRouteKey() }}');
}
});
</script>
@endif
<script>
document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer');
@@ -779,12 +866,23 @@
videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
playPromise.then(function() {}).catch(function() {});
}
@if($nextVideo && $playlist)
// Auto-advance to next playlist video when this one ends
videoPlayer.addEventListener('ended', function() {
window.location.href = '{{ route('videos.show', $nextVideo) }}?playlist={{ $playlist->share_token }}';
});
@endif
}
// Scroll the currently playing video into view in the playlist sidebar
var currentCard = document.querySelector('.sidebar-video-card.current-video');
if (currentCard) {
setTimeout(function() {
currentCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 400);
}
});
</script>
@@ -797,14 +797,14 @@
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
showToast('Failed to post comment: ' + error, 'error');
})
.then(data => {
if (data && data.success) {
document.getElementById('commentBody').value = '';
addCommentToList(data.comment);
} else {
alert('Failed to post comment');
showToast('Failed to post comment', 'error');
}
});
}
@@ -839,7 +839,7 @@
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
@@ -857,7 +857,7 @@
}
}
});
}
}, 'Delete');
}
document.addEventListener('DOMContentLoaded', function() {
@@ -2517,14 +2517,14 @@
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
showToast('Failed to post comment: ' + error, 'error');
})
.then(data => {
if (data && data.success) {
document.getElementById('commentBody').value = '';
addCommentToList(data.comment);
} else {
alert('Failed to post comment');
showToast('Failed to post comment', 'error');
}
});
}
@@ -2668,7 +2668,7 @@
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
@@ -2686,7 +2686,7 @@
}
}
});
}
}, 'Delete');
}
document.addEventListener('DOMContentLoaded', function() {
enhanceCommentBodyWithTimeBadges(document);
@@ -3510,7 +3510,7 @@
function requireValidTimeInput(value, fieldLabel) {
const parsed = parseMinuteSecondInput(value);
if (parsed === null) {
alert(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`);
showToast(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`, 'error');
return null;
}
return parsed;
@@ -3730,7 +3730,7 @@
// Round/Point/Review functions (abbreviated for brevity)
function openAddRoundModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode: Requires authentication');
showToast('Demo Mode: Requires authentication', 'info');
return;
}
document.getElementById('roundNumber').value = '';
@@ -3758,15 +3758,15 @@
if (data.success) {
closeModal('addRoundModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function editRound(roundNumber, roundId, currentName, currentStartTime = null) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
@@ -3834,7 +3834,7 @@
}
} catch (e) {
console.error('updateRound error:', e);
alert('Error updating round: ' + e.message);
showToast('Error updating round: ' + e.message, 'error');
}
}
async function deleteRound(roundId) {
@@ -3852,15 +3852,15 @@
});
const data = await res.json();
if (data.success) loadMatchData();
else alert(data.message || 'Error');
else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function openAddPointModal(roundNumber, roundId) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
document.getElementById('pointModalTitle').textContent = 'Add Point';
@@ -3929,7 +3929,7 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/points`, {
method: 'POST',
@@ -3950,10 +3950,10 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deletePoint(pointId) {
@@ -3973,9 +3973,9 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
@@ -3998,7 +3998,7 @@
function openAddReviewModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
const titleEl = document.getElementById('reviewModalTitle');
@@ -4077,7 +4077,7 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/reviews`, {
method: 'POST',
@@ -4097,10 +4097,10 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deleteReview(reviewId) {
@@ -4120,9 +4120,9 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
@@ -750,14 +750,14 @@
.then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
showToast('Failed to post comment: ' + error, 'error');
})
.then(data => {
if (data && data.success) {
document.getElementById('commentBody').value = '';
addCommentToList(data.comment);
} else {
alert('Failed to post comment');
showToast('Failed to post comment', 'error');
}
});
}
@@ -792,7 +792,7 @@
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
showConfirm('Are you sure you want to delete this comment?', function() {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
@@ -810,7 +810,7 @@
}
}
});
}
}, 'Delete');
}
document.addEventListener('DOMContentLoaded', function() {
+29 -225
View File
@@ -1,7 +1,23 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@section('extra_styles')
<style>
/* Video Section */
@@ -326,10 +342,6 @@
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container {
max-height: 50vh !important;
border-radius: 0 !important;
@@ -389,53 +401,11 @@
<!-- Video Section -->
<div class="yt-video-section">
<!-- Video Player -->
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
</video>
<!-- Playlist Navigation Controls (only show when in playlist) -->
@if ($nextVideo || $previousVideo)
<div class="playlist-controls" id="playlistControls">
<!-- Previous Video -->
@if ($previousVideo)
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<!-- Autoplay Toggle -->
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
<!-- Next Video -->
@if ($nextVideo)
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div>
<x-video-player :video="$video"
:next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null"
:playlist="$playlist ?? null"
:playlist-videos="$playlistVideos ?? null" />
<!-- Video Title with Film Icon (Generic Type) -->
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
@@ -454,75 +424,7 @@
<x-channel-row :video="$video" />
<!-- Description Box -->
@if ($video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
<div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content" id="descriptionContent">
@if ($needsExpand)
<div class="description-short" id="descShort">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
</div>
<div class="description-full" id="descFull" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div>
<button onclick="toggleDescription()" id="descToggleBtn"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
Show more
</button>
@else
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
@endif
</div>
</div>
<script>
function toggleDescription() {
const descShort = document.getElementById('descShort');
const descFull = document.getElementById('descFull');
const toggleBtn = document.getElementById('descToggleBtn');
if (descShort.style.display !== 'none') {
descShort.style.display = 'none';
descFull.style.display = 'block';
toggleBtn.textContent = 'Show less';
} else {
descShort.style.display = 'block';
descFull.style.display = 'none';
toggleBtn.textContent = 'Show more';
}
}
</script>
<style>
.video-description-box .description-text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
}
.video-description-box .description-text p {
margin-bottom: 8px;
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style>
@endif
@include('videos.partials.description-box', ['video' => $video])
<x-video-comments :video="$video" />
</div>
@@ -537,7 +439,7 @@
</script>
<!-- Sidebar - Up Next / Recommendations -->
<div class="yt-sidebar-container">
<div class="yt-sidebar-container" style="display: flex; flex-direction: column; gap: 16px;">
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
@@ -550,7 +452,7 @@
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -592,9 +494,8 @@
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
<a href="{{ route('playlists.show', $playlist->id) }}" class="action-btn" style="margin-top: 12px;">
<i class="bi bi-pencil"></i> <span>Edit Playlist</span>
</a>
@endif
@else
@@ -603,8 +504,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -645,113 +546,16 @@
</div>
@include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal'))
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ $video->id }});
openEditVideoModal('{{ $video->getRouteKey() }}');
});
</script>
@endauth
@endif
<script>
// Playlist navigation URLs
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
// Autoplay management
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer');
var playlistControls = document.getElementById('playlistControls');
var autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) {
videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
}
// Handle video ended - autoplay next
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', function() {
if (autoplayEnabled) {
console.log('Video ended, going to next...');
window.location.href = nextVideoUrl;
}
});
}
}
// Show playlist controls on hover
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', function() {
playlistControls.classList.add('visible');
});
videoContainer.addEventListener('mouseleave', function() {
playlistControls.classList.remove('visible');
});
// Initially show for a few seconds
setTimeout(function() {
playlistControls.classList.add('visible');
}, 2000);
setTimeout(function() {
playlistControls.classList.remove('visible');
}, 6000);
}
// Autoplay toggle handler
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
// Load saved preference
const savedAutoplay = localStorage.getItem('playlistAutoplay');
if (savedAutoplay !== null) {
autoplayEnabled = savedAutoplay === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToPreviousVideo();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToNextVideo();
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) {
window.location.href = previousVideoUrl;
}
}
function goToNextVideo() {
if (nextVideoUrl) {
window.location.href = nextVideoUrl;
}
}
</script>
@endsection
+84 -212
View File
@@ -1,6 +1,22 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@section('extra_styles')
<style>
/* ===== Video Section ===== */
@@ -332,28 +348,6 @@
gap: 8px;
}
.yt-action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
border: none;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.yt-action-btn:hover {
background: var(--border-color);
}
.yt-action-btn.liked {
color: var(--brand-red);
}
/* ===== Channel Row ===== */
.channel-row {
display: flex;
@@ -976,7 +970,7 @@
overflow: hidden;
width: 400px;
flex-shrink: 0;
height: var(--video-height, 70vh);
height: var(--sidebar-height, 400px);
display: none !important;
flex-direction: column;
}
@@ -1102,22 +1096,6 @@
color: var(--text-primary);
}
.btn-add {
background: var(--brand-red);
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-add:hover {
background: #dc2626;
}
.event-list {
margin-top: 12px;
@@ -1852,10 +1830,6 @@
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container {
max-height: 50vh !important;
border-radius: 0 !important;
@@ -1977,59 +1951,20 @@
<!-- Video Section -->
<div class="yt-video-section">
<!-- Video Player -->
<div class="video-container @if (isset($video->orientation) && $video->orientation === 'portrait') portrait @elseif(isset($video->orientation) && $video->orientation === 'square') square @elseif(isset($video->orientation) && $video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ isset($video) ? route('videos.stream', $video->id) : '#' }}" type="video/mp4">
</video>
<!-- Match Highlights Toggle Button -->
<button class="match-highlights-toggle" id="matchHighlightsToggle" title="Toggle Match Highlights">
<span>Highlights</span>
</button>
<!-- Coach note overlay (subtitle-style) -->
<div class="coach-note-overlay" id="coachNoteOverlay"></div>
<!-- Playlist Navigation Controls -->
@if (isset($nextVideo) || isset($previousVideo))
<div class="playlist-controls" id="playlistControls">
@if (isset($previousVideo))
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span
class="playlist-nav-label">{{ Str::limit($previousVideo->title ?? 'Previous', 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
@if (isset($nextVideo))
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title ?? 'Next', 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div>
<x-video-player :video="$video"
:next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null"
:playlist="$playlist ?? null"
:playlist-videos="$playlistVideos ?? null">
<x-slot:overlay>
{{-- Match highlights toggle --}}
<button class="match-highlights-toggle" id="matchHighlightsToggle" title="Toggle Match Highlights">
<span>Highlights</span>
</button>
{{-- Coach note overlay (subtitle-style) --}}
<div class="coach-note-overlay" id="coachNoteOverlay"></div>
</x-slot:overlay>
</x-video-player>
<!-- ===== EVENT HEADER ===== -->
<div class="event-header">
@@ -2088,8 +2023,10 @@
</div>
<!-- ===== END EVENT HEADER ===== -->
<!-- Description Box (Collapsible) -->
@if (isset($video) && $video->description)
@include('videos.partials.description-box', ['video' => $video])
{{-- ---- legacy match description panel (kept for reference, hidden) ---- --}}
@if (false && isset($video) && $video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
@@ -2272,33 +2209,11 @@
</section>
<script>
// Collapsible Description Toggle
const descriptionBody = document.getElementById('descriptionBody');
const descriptionToggle = document.getElementById('toggleDescription');
const descriptionToggleLabel = document.getElementById('descriptionToggleLabel');
const descriptionToggleIcon = document.getElementById('descriptionToggleIcon');
if (descriptionToggle && descriptionBody) {
descriptionToggle.addEventListener('click', () => {
const isOpen = descriptionBody.classList.toggle('open');
descriptionToggleLabel.textContent = isOpen ? 'Hide description' : 'Show description';
descriptionToggleIcon.textContent = isOpen ? '▲' : '▼';
});
}
// Hall/Venue Link Handler
const hallLink = document.getElementById('hallLink');
if (hallLink) {
hallLink.addEventListener('click', () => {
// Analytics tracking can be added here
});
}
// Match Highlights Toggle with localStorage persistence
document.addEventListener('DOMContentLoaded', function() {
const toggleBtn = document.getElementById('matchHighlightsToggle');
const sidebar = document.querySelector('.events-sidebar');
const videoId = {{ isset($video) ? $video->id : 0 }};
const videoId = {{ isset($video) ? json_encode($video->getRouteKey()) : "''" }};
if (toggleBtn && sidebar) {
// Load saved state on page load
@@ -2351,7 +2266,7 @@
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<button class="btn-add" type="button" onclick="openAddRoundModal()">
<button class="action-btn action-btn-primary" type="button" onclick="openAddRoundModal()">
<span>+</span> Add Round
</button>
@endif
@@ -2524,7 +2439,7 @@
<div class="section-label">Private notes</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<button class="btn-add" type="button" onclick="openAddReviewModal()">
<button class="action-btn action-btn-primary" type="button" onclick="openAddReviewModal()">
<span>+</span> Add Note
</button>
@endif
@@ -2625,7 +2540,7 @@
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -2667,9 +2582,8 @@
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
<a href="{{ route('playlists.show', $playlist->id) }}" class="action-btn" style="margin-top: 12px;">
<i class="bi bi-pencil"></i> <span>Edit Playlist</span>
</a>
@endif
@else
@@ -2678,8 +2592,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -2723,7 +2637,6 @@
</div>
<!-- Modals -->
@include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal')
<!-- Add Round Modal -->
@@ -2931,7 +2844,7 @@
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ isset($video) ? $video->id : 0 }});
openEditVideoModal('{{ isset($video) ? $video->getRouteKey() : '' }}');
});
</script>
@endauth
@@ -2956,12 +2869,8 @@
});
}
// Playlist navigation URLs
const previousVideoUrl = @json(isset($previousVideo) ? route('videos.show', $previousVideo->id) . '?playlist=' . ($playlist->id ?? '') : null);
const nextVideoUrl = @json(isset($nextVideo) ? route('videos.show', $nextVideo->id) . '?playlist=' . ($playlist->id ?? '') : null);
const videoId = {{ isset($video) ? $video->id : 0 }};
const videoId = {{ isset($video) ? json_encode($video->getRouteKey()) : "''" }};
const isOwner = {{ json_encode(Auth::check() && isset($video) && Auth::id() === $video->user_id) }};
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() {
loadMatchData();
@@ -3000,9 +2909,9 @@
const pointsContainer = document.getElementById('officialEvents');
const reviewsContainer = document.getElementById('reviewEvents');
if (pointsContainer) pointsContainer.innerHTML =
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No match data yet. <button class="btn-add" onclick="openAddRoundModal()">+ Add Round</button></div>';
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No match data yet. <button class="action-btn action-btn-primary" onclick="openAddRoundModal()">+ Add Round</button></div>';
if (reviewsContainer) reviewsContainer.innerHTML =
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No coach notes yet. <button class="btn-add" onclick="openAddReviewModal()">+ Add Note</button></div>';
'<div style="text-align: center; padding: 40px; color: var(--text-secondary);">No coach notes yet. <button class="action-btn action-btn-primary" onclick="openAddReviewModal()">+ Add Note</button></div>';
}
function renderMatchData(rounds, reviews) {
@@ -3124,12 +3033,29 @@
function requireValidTimeInput(value, fieldLabel) {
const parsed = parseMinuteSecondInput(value);
if (parsed === null) {
alert(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`);
showToast(`${fieldLabel} must be in mm.ss format (example: 1.30 for 1m 30s) or plain seconds.`, 'error');
return null;
}
return parsed;
}
// Sync sidebar height to video player height
function initSidebarHeightSync() {
const wrap = document.getElementById('ytpWrap');
if (!wrap) return;
const apply = () => {
const h = wrap.getBoundingClientRect().height;
if (h > 0) document.documentElement.style.setProperty('--sidebar-height', h + 'px');
};
apply();
new ResizeObserver(apply).observe(wrap);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSidebarHeightSync);
} else {
initSidebarHeightSync();
}
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.tab-button');
const tabPanels = document.querySelectorAll('.tab-panel');
@@ -3344,7 +3270,7 @@
// Round/Point/Review functions (abbreviated for brevity)
function openAddRoundModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode: Requires authentication');
showToast('Demo Mode: Requires authentication', 'info');
return;
}
document.getElementById('roundNumber').value = '';
@@ -3372,15 +3298,15 @@
if (data.success) {
closeModal('addRoundModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function editRound(roundNumber, roundId, currentName, currentStartTime = null) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
@@ -3448,7 +3374,7 @@
}
} catch (e) {
console.error('updateRound error:', e);
alert('Error updating round: ' + e.message);
showToast('Error updating round: ' + e.message, 'error');
}
}
async function deleteRound(roundId) {
@@ -3466,15 +3392,15 @@
});
const data = await res.json();
if (data.success) loadMatchData();
else alert(data.message || 'Error');
else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
function openAddPointModal(roundNumber, roundId) {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
document.getElementById('pointModalTitle').textContent = 'Add Point';
@@ -3543,7 +3469,7 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/points`, {
method: 'POST',
@@ -3564,10 +3490,10 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deletePoint(pointId) {
@@ -3587,9 +3513,9 @@
if (data.success) {
closeModal('pointModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
@@ -3612,7 +3538,7 @@
function openAddReviewModal() {
if (isDemoMode || !csrfToken) {
alert('Demo Mode');
showToast('Demo Mode', 'info');
return;
}
const titleEl = document.getElementById('reviewModalTitle');
@@ -3691,7 +3617,7 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} else {
const res = await fetch(`/videos/${videoId}/reviews`, {
method: 'POST',
@@ -3711,10 +3637,10 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
}
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
async function deleteReview(reviewId) {
@@ -3734,65 +3660,11 @@
if (data.success) {
closeModal('reviewModal');
loadMatchData();
} else alert(data.message || 'Error');
} else showToast(data.message || 'Error', 'error');
} catch (e) {
alert('Error: ' + e.message);
showToast('Error: ' + e.message, 'error');
}
}
// Playlist navigation
document.addEventListener('DOMContentLoaded', function() {
const videoPlayer = document.getElementById('videoPlayer');
const playlistControls = document.getElementById('playlistControls');
const autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) {
videoPlayer.volume = 0.5;
const playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(() => console.log('Autoplayed')).catch(() => console.log('Autoplay blocked'));
}
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', () => {
if (autoplayEnabled) window.location.href = nextVideoUrl;
});
}
}
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', () => playlistControls.classList.add('visible'));
videoContainer.addEventListener('mouseleave', () => playlistControls.classList.remove('visible'));
setTimeout(() => playlistControls.classList.add('visible'), 2000);
setTimeout(() => playlistControls.classList.remove('visible'), 6000);
}
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
const saved = localStorage.getItem('playlistAutoplay');
if (saved !== null) {
autoplayEnabled = saved === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
if (previousVideoUrl) window.location.href = previousVideoUrl;
} else if (e.key === 'ArrowRight') {
e.preventDefault();
if (nextVideoUrl) window.location.href = nextVideoUrl;
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) window.location.href = previousVideoUrl;
}
function goToNextVideo() {
if (nextVideoUrl) window.location.href = nextVideoUrl;
}
</script>
@endsection
+35 -249
View File
@@ -1,7 +1,23 @@
@extends('layouts.app')
@section('main_class', 'video-view-page')
@section('title', $video->title . ' | ' . config('app.name'))
@push('head')
<meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush
@section('extra_styles')
<style>
/* Video Section */
@@ -216,24 +232,6 @@
gap: 8px;
}
.yt-action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
border: none;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.yt-action-btn:hover {
background: var(--border-color);
}
.action-btn,
.comments-section .action-btn {
border: none;
@@ -275,11 +273,6 @@
border-color: var(--brand-red);
}
.yt-action-btn.liked {
color: var(--brand-red);
}
/* Description */
.video-description {
@@ -395,10 +388,6 @@
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container {
max-height: 50vh !important;
border-radius: 0 !important;
@@ -441,54 +430,17 @@
<!-- Video Section -->
<div class="yt-video-section">
<!-- Video Player -->
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
</video>
<!-- Playlist Navigation Controls (only show when in playlist) -->
@if ($nextVideo || $previousVideo)
<div class="playlist-controls" id="playlistControls">
<!-- Previous Video -->
@if ($previousVideo)
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<!-- Autoplay Toggle -->
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
<!-- Next Video -->
@if ($nextVideo)
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div>
@if($video->isAudioOnly())
{{-- ── Audio-only player ── --}}
@include('videos.partials.audio-player', ['video' => $video, 'nextVideo' => $nextVideo ?? null, 'previousVideo' => $previousVideo ?? null, 'playlist' => $playlist ?? null])
@else
{{-- ── Video player ── --}}
<x-video-player :video="$video"
:next-video="$nextVideo ?? null"
:previous-video="$previousVideo ?? null"
:playlist="$playlist ?? null"
:playlist-videos="$playlistVideos ?? null" />
@endif
<!-- Video Title with Music Note Icon (Music Type) -->
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
@@ -508,82 +460,14 @@
<!-- Channel Row - All in one line -->
<x-channel-row :video="$video" />
<!-- Description Box -->
@if ($video->description)
@php
$fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200;
@endphp
<div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span>
<span>•</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content" id="descriptionContent">
@if ($needsExpand)
<div class="description-short" id="descShort">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
</div>
<div class="description-full" id="descFull" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div>
<button onclick="toggleDescription()" id="descToggleBtn"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
Show more
</button>
@else
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
@endif
</div>
</div>
<script>
function toggleDescription() {
const descShort = document.getElementById('descShort');
const descFull = document.getElementById('descFull');
const toggleBtn = document.getElementById('descToggleBtn');
if (descShort.style.display !== 'none') {
descShort.style.display = 'none';
descFull.style.display = 'block';
toggleBtn.textContent = 'Show less';
} else {
descShort.style.display = 'block';
descFull.style.display = 'none';
toggleBtn.textContent = 'Show more';
}
}
</script>
<style>
.video-description-box .description-text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
}
.video-description-box .description-text p {
margin-bottom: 8px;
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style>
@endif
@include('videos.partials.description-box', ['video' => $video])
<x-video-comments :video="$video" />
</div>
<!-- Sidebar - Up Next / Recommendations -->
<div class="yt-sidebar-container">
<div class="yt-sidebar-container" style="display: flex; flex-direction: column; gap: 16px;">
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
@@ -596,7 +480,7 @@
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
@@ -638,9 +522,8 @@
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
<a href="{{ route('playlists.show', $playlist->id) }}" class="action-btn" style="margin-top: 12px;">
<i class="bi bi-pencil"></i> <span>Edit Playlist</span>
</a>
@endif
@else
@@ -649,8 +532,8 @@
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@@ -691,113 +574,16 @@
</div>
@include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal'))
@auth
<script>
document.addEventListener('DOMContentLoaded', function() {
openEditVideoModal({{ $video->id }});
openEditVideoModal('{{ $video->getRouteKey() }}');
});
</script>
@endauth
@endif
<script>
// Playlist navigation URLs
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
// Autoplay management
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer');
var playlistControls = document.getElementById('playlistControls');
var autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) {
videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play();
if (playPromise !== undefined) {
playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
}
// Handle video ended - autoplay next
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', function() {
if (autoplayEnabled) {
console.log('Video ended, going to next...');
window.location.href = nextVideoUrl;
}
});
}
}
// Show playlist controls on hover
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', function() {
playlistControls.classList.add('visible');
});
videoContainer.addEventListener('mouseleave', function() {
playlistControls.classList.remove('visible');
});
// Initially show for a few seconds
setTimeout(function() {
playlistControls.classList.add('visible');
}, 2000);
setTimeout(function() {
playlistControls.classList.remove('visible');
}, 6000);
}
// Autoplay toggle handler
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
// Load saved preference
const savedAutoplay = localStorage.getItem('playlistAutoplay');
if (savedAutoplay !== null) {
autoplayEnabled = savedAutoplay === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToPreviousVideo();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToNextVideo();
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) {
window.location.href = previousVideoUrl;
}
}
function goToNextVideo() {
if (nextVideoUrl) {
window.location.href = nextVideoUrl;
}
}
</script>
@endsection