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:
1 parent
d44490dfe0
commit
0b2e95ea65
161 files changed
+27496
-7250
No files matched your search
@@ -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
@@ -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()) · {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
|
||||
@if($searchPlaylists->count()) · {{ $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
|
||||
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user