Add multi-language audio tracks and self-hosted flag-icons

Introduce per-video language support and multiple audio tracks
(VideoAudioTrack model + migrations for language, description, title),
a reusable language-select component, and a track-editor form. Bundle
the self-hosted flag-icons v7.2.3 library and a NAS auto-sync command.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-22 21:32:52 +03:00
1 parent f8d13457fa
commit 66fd78c10f
314 files changed
+16507 -3172

No files matched your search

+490 -91
View File
@@ -88,6 +88,29 @@
.option-content span { font-size: 13px; color: #aaa; }
.option-item.active .option-content span { color: #fff; }
/* Language track accordion (create page) */
.lta-create { display: flex; flex-direction: column; gap: 6px; margin-bottom: 0; }
.ltac-item { background: #151515; border: 1px solid #333; border-radius: 10px; overflow: hidden; transition: border-color .2s; }
.ltac-item.open { border-color: rgba(230,30,30,.45); }
.ltac-header { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px; cursor: pointer; user-select: none; gap: 8px; }
.ltac-hd-left { display: flex; align-items: center; gap: 9px; flex: 1; min-width: 0; }
.ltac-num { width: 22px; height: 22px; border-radius: 50%; background: rgba(230,30,30,.15); border: 1px solid rgba(230,30,30,.3); display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: var(--brand-red); flex-shrink: 0; }
.ltac-flag-cell { flex-shrink: 0; }
.ltac-name { font-size: 13px; color: #888; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ltac-item.open .ltac-name { color: #e5e5e5; }
.ltac-arr { font-size: 12px; color: #555; transition: transform .2s, color .2s; flex-shrink: 0; }
.ltac-body { padding: 12px 14px 14px; border-top: 1px solid #2a2a2a; }
.ltac-item:not(.open) .ltac-body { display: none; }
.ltac-remove { background: none; border: none; color: #555; cursor: pointer; font-size: 13px; padding: 3px 6px; border-radius: 4px; transition: color .15s, background .15s; display: flex; align-items: center; }
.ltac-remove:hover { color: var(--brand-red); background: rgba(230,30,30,.1); }
.ltac-file-label { display: flex; align-items: center; gap: 8px; background: #121212; border: 1px solid #333; border-radius: 8px; padding: 10px 12px; cursor: pointer; transition: border-color .15s; }
.ltac-file-name { font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.ltac-slides-zone { border: 1.5px dashed #333; border-radius: 10px; min-height: 70px; background: #121212; cursor: pointer; transition: border-color .2s; display: flex; align-items: center; justify-content: center; }
.ltac-slides-zone:hover { border-color: var(--brand-red); }
.ltac-slides-zone.dragover { border-color: var(--brand-red); background: rgba(230,30,30,.05); }
.ltac-slides-ph { display: flex; align-items: center; gap: 8px; color: #555; font-size: 13px; padding: 16px; }
.ltac-slides-ph i { color: #444; }
/* Two Column Layout */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@@ -145,7 +168,7 @@
@csrf
<!-- Video File Dropzone -->
<div class="form-group">
<div class="form-group" id="file-dropzone-group">
<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/*,audio/mpeg,audio/mp4,audio/aac,audio/flac,audio/wav,.mp3,.m4a,.aac,.flac,.wav" required>
@@ -165,16 +188,16 @@
</div>
</div>
<!-- Title -->
<div class="form-group">
<label class="form-label">Title *</label>
<input type="text" name="title" id="video-title" required class="form-input" placeholder="Enter video title">
</div>
<!-- Description -->
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea>
<!-- Title + Description (video mode) -->
<div id="basic-fields-create">
<div class="form-group">
<label class="form-label">Title *</label>
<input type="text" name="title" id="video-title" class="form-input" placeholder="Enter video title">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea>
</div>
</div>
<!-- Thumbnail (video) -->
@@ -184,7 +207,7 @@
<input type="file" name="thumbnail" id="thumbnail" accept="image/*">
<div id="thumbnail-default">
<i class="bi bi-card-image"></i>
<p id="thumbnail-dropzone-hint">Click to select thumbnail (optional, max 20MB)</p>
<p id="thumbnail-dropzone-hint">Click to select thumbnail (optional)</p>
</div>
<div id="thumbnail-info">
<div style="display:flex;align-items:center;gap:12px;padding:12px;background:#1f1f1f;border-radius:8px;border:1px solid #333;">
@@ -201,28 +224,105 @@
</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>
<!-- Language Tracks accordion (audio mode only) -->
<div class="form-group" id="lang-tracks-section-create" style="display:none;">
<label class="form-label"><i class="bi bi-translate"></i> Language Tracks</label>
<div id="lta-create" class="lta-create">
<!-- Track 1 — always present, primary -->
<div class="ltac-item open" id="ltac-t1">
<div class="ltac-header" onclick="ltToggleCreate('t1')">
<div class="ltac-hd-left">
<span class="ltac-num">1</span>
<span class="ltac-flag-cell" id="ltac-t1-flag">
<span class="fi fi-xx lsd-flag" style="width:22px;height:16px;"></span>
</span>
<span class="ltac-name" id="ltac-t1-name">Primary — Select language</span>
</div>
<i class="bi bi-chevron-up ltac-arr" style="color:var(--brand-red);" id="ltac-t1-arr"></i>
</div>
<div class="ltac-body" id="ltac-t1-body">
<!-- Audio File Picker -->
<div style="margin-bottom:12px;" id="ltc-track1-file-wrap">
<label class="form-label" style="font-size:13px;margin-bottom:6px;"><i class="bi bi-music-note-beamed"></i> Audio File *</label>
<label class="ltac-file-label"
onclick="document.getElementById('video').click()"
onmouseenter="this.style.borderColor='var(--brand-red)'"
onmouseleave="this.style.borderColor='#333'">
<i class="bi bi-music-note-beamed" style="color:var(--brand-red);font-size:16px;flex-shrink:0;"></i>
<span class="ltac-file-name" id="ltc-track1-fname">Choose audio file…</span>
</label>
</div>
<!-- Title (audio mode) -->
<div style="margin-bottom:12px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Title *</label>
<input type="text" id="lt-track1-title-create" class="form-input"
style="font-size:13px;" placeholder="Track title…" autocomplete="off">
</div>
<!-- Description (audio mode) -->
<div style="margin-bottom:14px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Description</label>
<textarea id="lt-track1-desc-create" class="form-textarea"
rows="2" style="font-size:13px;min-height:64px;"
placeholder="Tell viewers about this track…"></textarea>
</div>
<!-- Slides -->
<div style="margin-bottom:14px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
<i class="bi bi-images"></i> Cover Slides <span style="color:var(--brand-red);">*</span>
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:4px;">drag to reorder</span>
</label>
<div id="slides-zone-ct1" class="ltac-slides-zone"
onclick="cSlidesZoneClick(event,'ct1')"
ondragover="cSlidesZoneDragover(event,'ct1')"
ondragleave="cSlidesZoneDragleave(event,'ct1')"
ondrop="cSlidesZoneDrop(event,'ct1')">
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-ct1"
onchange="handleCSlidesForTrack('ct1',this.files)">
<div class="ltac-slides-ph" id="slides-ph-ct1">
<i class="bi bi-images" style="font-size:22px;"></i>
<span>Click or drag to add cover images</span>
</div>
<div id="slides-preview-ct1" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
<div id="slides-strip-ct1" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span id="slides-count-ct1" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="clearCSlidesForTrack(event,'ct1')"
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
</div>
</div>
</div>
<!-- Primary Language -->
<div>
<x-language-select
name="primary_language"
id="primary_language_create"
label="Language"
placeholder="Select language"
/>
</div>
</div>
</div>
<!-- Extra tracks appended here by JS -->
<div id="ltac-extra"></div>
</div>
<button type="button" class="action-btn" onclick="addLangTrackCreate()"
style="font-size:13px;margin-top:8px;">
<i class="bi bi-plus-circle"></i> <span>Add Language Track</span>
</button>
</div>
<!-- Video Type -->
<div class="options-section">
<span class="options-label">Video Type</span>
@@ -345,9 +445,16 @@
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;
let _pageUploadInProgress = false;
// Warn before navigating away when a file is selected or upload is running
window.addEventListener('beforeunload', function(e) {
if (currentFile && !_pageUploadInProgress) {
e.preventDefault();
e.returnValue = '';
}
});
// ── Helpers ──────────────────────────────────────────────
function formatFileSize(bytes) {
@@ -367,10 +474,29 @@
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)' : '';
// Show/hide sections
document.getElementById('file-dropzone-group').style.display = audio ? 'none' : '';
document.getElementById('ltc-track1-file-wrap').style.display = audio ? '' : 'none';
document.getElementById('basic-fields-create').style.display = audio ? 'none' : '';
document.getElementById('thumbnail-section').style.display = audio ? 'none' : '';
document.getElementById('lang-tracks-section-create').style.display = audio ? '' : 'none';
if (audio) {
const z = document.getElementById('slides-zone-ct1');
if (z && !(_cSlidesData['ct1'] && _cSlidesData['ct1'].length)) z.style.borderColor = 'var(--brand-red)';
}
// Swap name attributes so only the active set submits
const outerTitle = document.getElementById('video-title');
const outerDesc = document.getElementById('video-description');
const innerTitle = document.getElementById('lt-track1-title-create');
const innerDesc = document.getElementById('lt-track1-desc-create');
if (audio) {
outerTitle.removeAttribute('name'); outerDesc.removeAttribute('name');
innerTitle.setAttribute('name', 'title'); innerDesc.setAttribute('name', 'description');
} else {
outerTitle.setAttribute('name', 'title'); outerDesc.setAttribute('name', 'description');
innerTitle.removeAttribute('name'); innerDesc.removeAttribute('name');
}
if (audio) {
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
@@ -399,7 +525,6 @@
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();
@@ -410,20 +535,22 @@
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;
if (audio) {
document.getElementById('lt-track1-title-create').value = titleWithoutExt;
} else {
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');
const cFname = document.getElementById('ltc-track1-fname');
if (cFname) cFname.textContent = file.name;
}
function removeVideo(e) {
@@ -431,7 +558,11 @@
fileInput.value = ''; currentFile = null;
document.getElementById('dropzone-default').style.display = 'block';
document.getElementById('file-info').classList.remove('active');
const cFname = document.getElementById('ltc-track1-fname');
if (cFname) cFname.textContent = 'Choose audio file…';
document.getElementById('video-title').value = '';
document.getElementById('lt-track1-title-create').value = '';
document.getElementById('lt-track1-desc-create').value = '';
setAudioMode(false);
}
@@ -445,6 +576,8 @@
_thumbClickTs = now;
if (typeof window.openCropperModal_thumb_create_mobile === 'function') {
window.openCropperModal_thumb_create_mobile();
const internal = document.getElementById('tcInput_thumb_create_mobile');
if (internal) internal.click();
} else {
thumbnailInput.click();
}
@@ -487,54 +620,119 @@
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); }
});
// ── Per-track slides management (create page) ────────────
const _cSlidesData = {}; // trackId → File[]
let _cSlidesDragSrc = null;
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 cSlidesZoneClick(e, trackId) {
if (e.target.closest('button')) return;
document.getElementById('slides-input-' + trackId)?.click();
}
function cSlidesZoneDragover(e, trackId) {
if (_cSlidesDragSrc) { e.preventDefault(); return; }
if (Array.from(e.dataTransfer.types || []).includes('Files')) {
e.preventDefault();
document.getElementById('slides-zone-' + trackId)?.classList.add('dragover');
}
}
function cSlidesZoneDragleave(e, trackId) {
document.getElementById('slides-zone-' + trackId)?.classList.remove('dragover');
}
function cSlidesZoneDrop(e, trackId) {
e.preventDefault();
document.getElementById('slides-zone-' + trackId)?.classList.remove('dragover');
if (_cSlidesDragSrc) return;
if (e.dataTransfer.files.length) handleCSlidesForTrack(trackId, e.dataTransfer.files);
}
function removeSlides(e) {
function handleCSlidesForTrack(trackId, fileList) {
if (!fileList || !fileList.length) return;
if (!_cSlidesData[trackId]) _cSlidesData[trackId] = [];
for (const f of Array.from(fileList)) {
if (_cSlidesData[trackId].length >= 10) break;
_cSlidesData[trackId].push(f);
}
renderCSlidesStrip(trackId);
}
function renderCSlidesStrip(trackId) {
const files = _cSlidesData[trackId] || [];
const strip = document.getElementById('slides-strip-' + trackId);
const ph = document.getElementById('slides-ph-' + trackId);
const prev = document.getElementById('slides-preview-' + trackId);
const cnt = document.getElementById('slides-count-' + trackId);
const zone = document.getElementById('slides-zone-' + trackId);
if (!strip) return;
strip.innerHTML = '';
if (!files.length) {
if (ph) ph.style.display = ''; if (prev) prev.style.display = 'none';
if (zone) zone.style.borderColor = ''; return;
}
files.forEach((f, index) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = trackId;
wrap.addEventListener('dragstart', function(ev) {
_cSlidesDragSrc = { trackId, index: parseInt(this.dataset.index) };
ev.dataTransfer.effectAllowed = 'move';
setTimeout(() => { this.style.opacity = '0.4'; }, 0);
});
wrap.addEventListener('dragend', function() { _cSlidesDragSrc = null; this.style.opacity = ''; });
wrap.addEventListener('dragover', function(ev) {
if (!_cSlidesDragSrc || _cSlidesDragSrc.trackId !== trackId) return;
ev.preventDefault(); this.style.outline = '2px solid var(--brand-red)';
});
wrap.addEventListener('dragleave', function() { this.style.outline = ''; });
wrap.addEventListener('drop', function(ev) {
ev.preventDefault(); ev.stopPropagation(); this.style.outline = '';
if (!_cSlidesDragSrc || _cSlidesDragSrc.trackId !== trackId) return;
const from = _cSlidesDragSrc.index, to = parseInt(this.dataset.index);
if (from === to) return;
const arr = _cSlidesData[trackId];
const [moved] = arr.splice(from, 1); arr.splice(to, 0, moved);
renderCSlidesStrip(trackId);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.style.cssText = 'position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:var(--brand-red);border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
removeBtn.innerHTML = '<i class="bi bi-x"></i>';
removeBtn.addEventListener('click', function(ev) {
ev.stopPropagation(); _cSlidesData[trackId].splice(index, 1); renderCSlidesStrip(trackId);
});
const img = document.createElement('img');
img.style.cssText = 'width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;';
img.alt = '';
const reader = new FileReader();
reader.onload = ev2 => { img.src = ev2.target.result; };
reader.readAsDataURL(f);
wrap.appendChild(img); wrap.appendChild(removeBtn); strip.appendChild(wrap);
});
if (cnt) cnt.textContent = files.length === 1 ? '1 image — static cover' : files.length + ' images — will crossfade';
if (ph) ph.style.display = 'none'; if (prev) prev.style.display = '';
if (zone) zone.style.borderColor = '#22c55e';
}
function clearCSlidesForTrack(e, trackId) {
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)';
_cSlidesData[trackId] = [];
const input = document.getElementById('slides-input-' + trackId);
if (input) {
const fresh = input.cloneNode(false);
fresh.addEventListener('change', function() { handleCSlidesForTrack(trackId, this.files); });
input.parentNode.replaceChild(fresh, input);
}
renderCSlidesStrip(trackId);
}
// ── Option selectors ─────────────────────────────────────
document.querySelectorAll('#type-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
item.classList.add('active');
item.addEventListener('click', function() {
document.querySelectorAll('#type-options .option-item').forEach(o => {
o.classList.toggle('active', o.dataset.type === this.dataset.type);
const inp = o.querySelector('input');
if (inp) inp.checked = (o.dataset.type === this.dataset.type);
});
setAudioMode(this.dataset.type === 'music');
});
});
document.querySelectorAll('#visibility-options .option-item').forEach(item => {
@@ -557,21 +755,35 @@
if (!currentFile) { showError('Please select a video or audio file.'); return; }
const title = document.getElementById('video-title').value.trim();
const isAudio = isAudioFile(currentFile);
const titleEl = isAudio ? document.getElementById('lt-track1-title-create') : document.getElementById('video-title');
const title = titleEl.value.trim();
if (!title) { showError('Please enter a title.'); return; }
if (isAudioFile(currentFile)) {
if (!slidesInput.files || !slidesInput.files.length) {
const _isAudioSubmitC = isAudioFile(currentFile);
if (_isAudioSubmitC) {
const t1Slides = _cSlidesData['ct1'] || [];
if (!t1Slides.length) {
showError('At least one cover image is required for audio tracks.');
slidesDropzone.style.borderColor = 'var(--brand-red)';
const z = document.getElementById('slides-zone-ct1');
if (z) z.style.borderColor = 'var(--brand-red)';
return;
}
}
const formData = new FormData(this);
if (_isAudioSubmitC) {
(_cSlidesData['ct1'] || []).forEach(f => formData.append('slides[]', f));
for (const [tid, files] of Object.entries(_cSlidesData)) {
if (tid === 'ct1') continue;
const n = parseInt(tid.replace('ce', ''));
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
}
}
const xhr = new XMLHttpRequest();
xhr.timeout = 1800000; // 30 min
xhr.timeout = 0; // no timeout — unlimited file size
_pageUploadInProgress = true;
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...';
@@ -593,8 +805,12 @@
try {
const res = JSON.parse(xhr.responseText);
if (res.success) {
document.getElementById('progress-text').textContent = 'Done! Redirecting...';
window.location.href = res.redirect;
document.getElementById('progress-container').classList.remove('active');
const sm = document.getElementById('status-message');
sm.innerHTML = '<i class="bi bi-check-circle-fill"></i> Upload successful! Taking you to your video…';
sm.className = 'success';
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-check-lg"></i> <span>Uploaded!</span>';
setTimeout(() => { window.location.href = res.redirect; }, 1800);
} else {
showError(res.message || 'Upload failed.');
}
@@ -613,7 +829,7 @@
});
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
xhr.addEventListener('timeout', () => showError('Upload timed out. The file may be too large.'));
xhr.addEventListener('timeout', () => showError('Upload timed out.'));
xhr.open('POST', '{{ route("videos.store") }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
@@ -621,13 +837,196 @@
});
function showError(message) {
_pageUploadInProgress = false;
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> <span>Upload Video</span>';
const isAudio = document.querySelector('#type-options .option-item[data-type="music"]')?.classList.contains('active');
document.getElementById('submit-btn').innerHTML =
'<i class="bi bi-cloud-arrow-up-fill"></i> <span>' + (isAudio ? 'Upload Track' : 'Upload Video') + '</span>';
document.getElementById('progress-container').classList.remove('active');
}
// ── Language track accordion (create page) ───────────────
const LANG_OPTIONS_CREATE = @json(\App\Data\Languages::forLanguage());
let _ltCreateExtraCounter = 0;
function _ltcBuildOptsHtml() {
return LANG_OPTIONS_CREATE.map(o =>
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
<span class="csd-opt-main">${o.label}</span>
<span class="csd-opt-sub">${o.native}</span>
</li>`
).join('');
}
function ltToggleCreate(trackId) {
const item = document.getElementById('ltac-' + trackId);
const arr = document.getElementById('ltac-' + trackId + '-arr');
if (!item) return;
const isOpen = item.classList.contains('open');
if (isOpen) {
item.classList.remove('open');
if (arr) { arr.style.transform = ''; arr.style.color = ''; }
} else {
item.classList.add('open');
if (arr) { arr.style.transform = 'rotate(180deg)'; arr.style.color = 'var(--brand-red)'; }
}
}
function updateLtacHeader(trackId, langCode) {
const lang = LANG_OPTIONS_CREATE.find(o => o.value === langCode);
if (!lang) return;
const flagEl = document.getElementById('ltac-' + trackId + '-flag');
const nameEl = document.getElementById('ltac-' + trackId + '-name');
if (flagEl) flagEl.innerHTML = `<span class="fi fi-${lang.flag} lsd-flag" style="width:22px;height:16px;"></span>`;
if (nameEl) {
const prefix = trackId === 't1' ? 'Primary' : 'Track ' + (parseInt(trackId.replace('e', '')) + 1);
nameEl.textContent = prefix + ' — ' + lang.label;
}
}
function ltRemoveCreate(e, trackId) {
e.preventDefault();
e.stopPropagation();
const item = document.getElementById('ltac-' + trackId);
if (item) item.remove();
delete _cSlidesData['c' + trackId];
}
function addLangTrackCreate() {
const n = ++_ltCreateExtraCounter;
const trackNum = n + 1;
const uid = 'ltce_' + n;
const item = document.createElement('div');
item.className = 'ltac-item open';
item.id = 'ltac-e' + n;
item.innerHTML = `
<div class="ltac-header" onclick="ltToggleCreate('e${n}')">
<div class="ltac-hd-left">
<span class="ltac-num">${trackNum}</span>
<span class="ltac-flag-cell" id="ltac-e${n}-flag">
<span class="fi fi-xx lsd-flag" style="width:22px;height:16px;"></span>
</span>
<span class="ltac-name" id="ltac-e${n}-name">Track ${trackNum} — Select language</span>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<button type="button" class="ltac-remove" onclick="ltRemoveCreate(event,'e${n}')" title="Remove track">
<i class="bi bi-trash"></i>
</button>
<i class="bi bi-chevron-up ltac-arr" style="color:var(--brand-red);" id="ltac-e${n}-arr"></i>
</div>
</div>
<div class="ltac-body" id="ltac-e${n}-body">
<div style="margin-bottom:12px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;"><i class="bi bi-music-note-beamed"></i> Audio File</label>
<label class="ltac-file-label"
onmouseenter="this.style.borderColor='var(--brand-red)'"
onmouseleave="this.style.borderColor='#333'">
<i class="bi bi-music-note-beamed" style="color:var(--brand-red);font-size:16px;flex-shrink:0;"></i>
<span class="ltac-file-name" id="ltac-e${n}-fname">Choose audio file…</span>
<input type="file" name="extra_track_files[]"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('ltac-e${n}-fname').textContent=this.files[0]?.name||'Choose audio file…'">
</label>
</div>
<div style="margin-bottom:12px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
Localized Title <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional</span>
</label>
<input type="text" name="extra_track_titles[]" class="form-input"
style="font-size:13px;" placeholder="Title in this language…">
</div>
<div style="margin-bottom:12px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
Localized Description <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional</span>
</label>
<textarea name="extra_track_descriptions[]" class="form-textarea"
rows="2" style="font-size:13px;min-height:64px;"
placeholder="Description in this language…"></textarea>
</div>
<div style="margin-bottom:12px;">
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
Cover Slides <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional · drag to reorder</span>
</label>
<div id="slides-zone-ce${n}" class="ltac-slides-zone"
onclick="cSlidesZoneClick(event,'ce${n}')"
ondragover="cSlidesZoneDragover(event,'ce${n}')"
ondragleave="cSlidesZoneDragleave(event,'ce${n}')"
ondrop="cSlidesZoneDrop(event,'ce${n}')">
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-ce${n}"
onchange="handleCSlidesForTrack('ce${n}',this.files)">
<div class="ltac-slides-ph" id="slides-ph-ce${n}">
<i class="bi bi-images" style="font-size:22px;"></i>
<span>Click or drag to add cover images</span>
</div>
<div id="slides-preview-ce${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
<div id="slides-strip-ce${n}" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span id="slides-count-ce${n}" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="clearCSlidesForTrack(event,'ce${n}')"
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
</div>
</div>
</div>
<div>
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Language</label>
<div class="csd-wrap" id="${uid}">
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
<span class="csd-val ph">Select language</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch"><i class="bi bi-search"></i><input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false"></div>
<ul class="csd-list">${_ltcBuildOptsHtml()}</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
</div>
</div>
</div>`;
document.getElementById('ltac-extra').appendChild(item);
_cSlidesData['ce' + n] = [];
if (window.CSD) new CSD(uid);
const hiddenInput = document.getElementById('csd_v_' + uid);
if (hiddenInput) {
hiddenInput.dataset.trackId = 'e' + n;
hiddenInput.addEventListener('change', function() {
updateLtacHeader('e' + n, this.value);
});
}
}
// Wire primary language select → update track 1 header
;(function() {
const plInput = document.getElementById('primary_language_create');
if (plInput) {
plInput.dataset.trackId = 't1';
plInput.addEventListener('change', function() {
updateLtacHeader('t1', this.value);
});
}
// Delegated fallback: catches any language hidden-input change within the tracks section
const section = document.getElementById('lang-tracks-section-create');
if (section) {
section.addEventListener('change', function(e) {
const tid = e.target.dataset && e.target.dataset.trackId;
if (tid && e.target.value) updateLtacHeader(tid, e.target.value);
});
}
}());
</script>
<x-image-cropper
id="thumb_create_mobile"
+136
View File
@@ -246,6 +246,63 @@
<span id="ep-slides-hint" style="font-size:12px;color:var(--text-secondary);"></span>
</div>
</div>
{{-- Primary Language (audio only) --}}
<div class="form-group">
<x-language-select
name="primary_language"
id="ep_primary_language"
label="Track Language"
placeholder="Select language"
value="{{ $video->language ?? '' }}"
/>
</div>
{{-- Language Tracks Manager (audio only) --}}
<div class="form-group">
<label class="form-label"><i class="bi bi-translate"></i> Language Tracks</label>
{{-- Existing tracks --}}
<div id="ep-tracks-existing" style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px;">
@forelse($video->audioTracks as $track)
<div style="display:flex;flex-direction:column;gap:6px;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 12px;" data-track-id="{{ $track->id }}" id="ep-track-{{ $track->id }}">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:11px;font-weight:700;background:rgba(255,255,255,.1);padding:2px 6px;border-radius:4px;font-family:monospace;">{{ strtoupper($track->language) }}</span>
<span style="flex:1;font-size:13px;color:#999;">{{ $track->label }}</span>
<a href="{{ route('videos.audio-track', ['video' => $video, 'track' => $track->id]) }}" target="_blank" style="font-size:12px;color:#888;" title="Preview"><i class="bi bi-play-circle"></i></a>
<input type="hidden" name="delete_track_ids[]" id="ep-del-{{ $track->id }}" value="" disabled>
<button type="button" onclick="_epMarkDelTrack({{ $track->id }}, this)" style="background:none;border:none;color:#888;cursor:pointer;padding:2px 4px;font-size:14px;" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
<input type="text" name="track_title_updates[{{ $track->id }}]"
value="{{ $track->title ?? '' }}"
placeholder="Title in this language…"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;margin-bottom:6px;">
<textarea name="track_description_updates[{{ $track->id }}]"
placeholder="Description in this language…" rows="2"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;resize:none;margin-bottom:6px;">{{ $track->description ?? '' }}</textarea>
<label style="display:flex;align-items:center;gap:8px;background:#0d0d0d;border:1px solid #222;border-radius:6px;padding:8px 10px;cursor:pointer;"
onmouseenter="this.style.borderColor='#e61e1e'" onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:14px;flex-shrink:0;"></i>
<span style="font-size:12px;color:#888;flex:1;" id="ep-track-fname-{{ $track->id }}">Keep existing / choose new file…</span>
<input type="file" name="track_file_updates[{{ $track->id }}]"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('ep-track-fname-{{ $track->id }}').textContent=this.files[0]?.name||'Keep existing / choose new file…'">
</label>
</div>
@empty
<p id="ep-tracks-empty-msg" style="font-size:12px;color:#888;margin:0 0 4px;">No extra language tracks yet.</p>
@endforelse
</div>
{{-- New tracks to add --}}
<div id="ep-tracks-new-list" style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px;"></div>
<button type="button" class="action-btn" onclick="addEpTrackRow(event)" style="font-size:13px;">
<i class="bi bi-plus-circle"></i> <span>Add Language Track</span>
</button>
</div>
@endif
{{-- Video Type --}}
@@ -372,6 +429,8 @@
if (e.target.closest('.btn-remove')) return;
if (typeof window.openCropperModal_thumb_edit_mobile === 'function') {
window.openCropperModal_thumb_edit_mobile();
const internal = document.getElementById('tcInput_thumb_edit_mobile');
if (internal) internal.click();
} else {
editThumbInput.click();
}
@@ -536,6 +595,9 @@
@if($video->isAudioOnly())
// Append newly added slide files
epSlidesData.filter(s => s.file).forEach(s => formData.append('slides_add[]', s.file, s.file.name));
// Append new extra track files (already have name="extra_track_files[]" in DOM)
// delete_track_ids are already wired via enabled hidden inputs
@endif
fetch('{{ route("videos.update", $video) }}', {
@@ -561,6 +623,80 @@
.catch(() => showEditError('Something went wrong. Please try again.'));
});
@if($video->isAudioOnly())
// ── Language track management ─────────────────────────────────────────────
const EP_LANG_OPTIONS = @json(\App\Data\Languages::forLanguage());
let _epTrackCounter = 0;
function _epMarkDelTrack(trackId, btn) {
const hiddenInput = document.getElementById('ep-del-' + trackId);
if (hiddenInput) {
hiddenInput.value = trackId;
hiddenInput.disabled = false;
}
btn.closest('[data-track-id]').remove();
const existing = document.getElementById('ep-tracks-existing');
if (existing && existing.querySelectorAll('[data-track-id]').length === 0) {
let msg = document.getElementById('ep-tracks-empty-msg');
if (!msg) {
msg = document.createElement('p');
msg.id = 'ep-tracks-empty-msg';
msg.style.cssText = 'font-size:12px;color:#888;margin:0 0 4px;';
msg.textContent = 'No extra language tracks yet.';
existing.appendChild(msg);
}
}
}
function buildLangSelectHtmlEp(uid) {
const opts = EP_LANG_OPTIONS.map(o =>
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
<span class="csd-opt-main">${o.label}</span>
<span class="csd-opt-sub">${o.native}</span>
</li>`
).join('');
return `<div class="csd-wrap" id="csd_${uid}">
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
<span class="csd-val ph">Select language</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch"><i class="bi bi-search"></i><input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false"></div>
<ul class="csd-list">${opts}</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
</div>`;
}
function addEpTrackRow(e) {
if (e) e.preventDefault();
const uid = 'epet_' + (++_epTrackCounter);
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex-direction:column;gap:8px;background:#151515;border:1px solid #333;border-radius:8px;padding:10px 12px;';
row.innerHTML = `
<div style="display:flex;gap:8px;align-items:center;">
<div style="flex:1;min-width:0;">${buildLangSelectHtmlEp(uid)}</div>
<button type="button" onclick="this.closest('[style*=flex-direction]').remove();" style="background:none;border:none;color:#888;cursor:pointer;padding:4px;font-size:16px;flex-shrink:0;" title="Remove">
<i class="bi bi-x-lg"></i>
</button>
</div>
<input type="text" name="extra_track_titles[]"
placeholder="Title in this language…"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;">
<label style="display:flex;align-items:center;gap:8px;background:#121212;border:1px solid #333;border-radius:6px;padding:8px 10px;cursor:pointer;">
<i class="bi bi-music-note-beamed" style="color:var(--brand-red);font-size:16px;"></i>
<span class="et-filename-${uid}" style="font-size:12px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;">Choose audio file</span>
<input type="file" name="extra_track_files[]" accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="this.parentNode.querySelector('.et-filename-${uid}').textContent=this.files[0]?.name||'Choose audio file';">
</label>`;
document.getElementById('ep-tracks-new-list').appendChild(row);
if (window.CSD) new CSD('csd_' + uid);
}
@endif
function showEditError(message) {
const status = document.getElementById('edit-status');
status.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> ' + message;
@@ -1,11 +1,35 @@
@php
$audioUrl = route('videos.stream', $video);
$audioUrl = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
$coverUrl = $video->thumbnail ? route('media.thumbnail', $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) => route('media.thumbnail', $s->filename))->values()->all()
: [];
// Build all-tracks list: primary first, then extra language tracks (skip extras that duplicate primary language)
$primaryLang = $video->language ?? 'default';
$allLangData = \App\Data\Languages::all();
$primaryFlag = $allLangData[$primaryLang]['flag'] ?? null;
$allAudioTracks = collect([[
'id' => 0,
'language' => $primaryLang,
'label' => $video->language ? strtoupper($video->language) : 'Default',
'flag' => $primaryFlag,
'stream_url' => $audioUrl,
'title' => $video->title,
'description' => $video->description ?? '',
'dl_url' => route('videos.downloadMp3', $video),
]])->concat($video->audioTracks->map(fn($t) => [
'id' => $t->id,
'language' => $t->language,
'label' => $t->label,
'flag' => $allLangData[$t->language]['flag'] ?? null,
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
'title' => $t->title ?? '',
'description' => $t->description ?? '',
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
]));
$hasMultipleTracks = $allAudioTracks->count() > 1;
@endphp
<div class="ytp-wrap" id="ytpWrap">
@@ -83,6 +107,34 @@
<div class="ytp-right-controls">
{{-- Language flag — always in DOM; hidden only when no language/flag is set --}}
<div class="ytp-lang-wrap" id="ytpLangWrap"@if(!$primaryFlag) style="display:none"@endif>
<button class="ytp-button ytp-lang-btn" id="ytpLangBtn" title="Language">
@if($primaryFlag)
<span class="fi fi-{{ $primaryFlag }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>
@else
<svg viewBox="0 0 24 24" style="width:22px;height:22px;fill:#fff;"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.9 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2s.07-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.66-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/></svg>
@endif
</button>
<div class="ytp-lang-popup" id="ytpLangPopup">
<div class="ytp-lang-popup-hdr">Language</div>
@foreach($allAudioTracks as $track)
<div class="ytp-lang-option {{ $loop->first ? 'active' : '' }}"
data-lang-id="{{ $track['id'] }}"
data-lang-url="{{ $track['stream_url'] }}"
data-lang-label="{{ $track['label'] }}"
data-lang-flag="{{ $track['flag'] ?? '' }}"
data-lang-title="{{ $track['title'] ?? '' }}"
data-lang-description="{{ $track['description'] ?? '' }}"
data-lang-dl-url="{{ $track['dl_url'] }}">
<span class="fi fi-{{ $track['flag'] ?: 'xx' }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;"></span>
<span class="ytp-lang-opt-label">{{ $track['label'] }}</span>
<svg class="ytp-lang-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>
</div>
@endforeach
</div>
</div>
<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>
@@ -106,16 +158,14 @@
</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>
{{-- Loop — standalone button, outside gear --}}
<button class="ytp-button ytp-loop-btn" id="ytpLoopBtn" title="Loop">
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
</button>
{{-- Bars visualiser toggle --}}
<button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off">
<svg viewBox="0 0 24 24">
@@ -147,17 +197,16 @@
.audio-cover-img {
position: absolute;
inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block;
object-fit: contain; 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;
object-fit: contain; 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; }
@@ -174,7 +223,7 @@
.ytp-wrap {
position: relative; width: 100%; background: #000;
border-radius: 12px; overflow: hidden;
aspect-ratio: 16/9; max-height: 70vh;
aspect-ratio: 16/9;
}
.ytp {
position: relative; width: 100%; height: 100%;
@@ -270,6 +319,32 @@
.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; }
/* ── Standalone language button ──────────────────────────────────── */
.ytp-lang-wrap { position: relative; }
.ytp-lang-btn { display: flex; align-items: center; justify-content: center; opacity: .85; transition: opacity .2s; }
.ytp-lang-btn:hover { opacity: 1; }
.ytp-lang-popup {
display: none; position: absolute; bottom: 48px; right: 0;
background: rgba(28,28,28,.97); border-radius: 12px;
min-width: 180px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,.7); z-index: 100;
}
.ytp-lang-popup.open { display: block; }
.ytp-lang-popup-hdr {
padding: 10px 14px 8px; font-size: 11px; font-weight: 700; letter-spacing: .5px;
text-transform: uppercase; color: rgba(255,255,255,.45);
border-bottom: 1px solid rgba(255,255,255,.1);
}
.ytp-lang-option {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; color: #fff; font-size: 13px;
cursor: pointer; transition: background .15s;
}
.ytp-lang-option:hover { background: rgba(255,255,255,.1); }
.ytp-lang-opt-label { flex: 1; }
.ytp-lang-check { width: 16px; height: 16px; fill: #fff; opacity: 0; flex-shrink: 0; }
.ytp-lang-option.active .ytp-lang-check { opacity: 1; }
.ytp-settings-wrap { position: relative; }
.ytp-settings-panel {
display: none; position: absolute; bottom: 44px; right: 0;
@@ -331,8 +406,13 @@
}
.ytp-toggle-row.is-on .ytp-toggle-thumb { transform: translateX(12px); }
/* Standalone loop button */
.ytp-loop-btn svg { opacity: .75; transition: opacity .15s, fill .15s; }
.ytp-loop-btn:hover svg { opacity: 1; }
.ytp-loop-btn.is-on svg { fill: #e61e1e; opacity: 1; }
@media (max-width: 576px) {
.ytp-wrap { border-radius: 0; max-height: 56vw; }
.ytp-wrap { border-radius: 0; }
.ytp-button { width: 32px; height: 32px; }
.ytp-button svg { width: 18px; height: 18px; }
.ytp-time-display { font-size: 11px; padding: 0 4px; }
@@ -365,8 +445,7 @@ 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 loopBtn = document.getElementById('ytpLoopBtn');
const barsBtn = document.getElementById('ytpBarsBtn');
const animCanvas = document.getElementById('audioAnimCanvas');
@@ -502,6 +581,150 @@ speedOpts.forEach(opt => {
});
});
// ── Language track switching ──────────────────────────────────
const langBtn = document.getElementById('ytpLangBtn');
const langPopup = document.getElementById('ytpLangPopup');
const langOpts = document.querySelectorAll('.ytp-lang-option');
if (langBtn && langPopup) {
langBtn.addEventListener('click', e => {
e.stopPropagation();
// Only open popup when there are multiple language options
if (langPopup.querySelectorAll('.ytp-lang-option').length < 2) return;
settingsPanel.classList.remove('open');
langPopup.classList.toggle('open');
});
document.addEventListener('click', () => langPopup.classList.remove('open'));
langPopup.addEventListener('click', e => e.stopPropagation());
langOpts.forEach(opt => {
opt.addEventListener('click', e => {
e.stopPropagation();
const url = opt.dataset.langUrl;
const flag = opt.dataset.langFlag;
if (!url) return;
const relPos = audio.duration ? audio.currentTime / audio.duration : 0;
const wasPlaying = !audio.paused;
const _vol = audio.volume, _muted = audio.muted;
audio.src = url; audio.load();
audio.volume = _vol; audio.muted = _muted;
audio.addEventListener('loadedmetadata', function() {
if (audio.duration) audio.currentTime = relPos * audio.duration;
updateProgress();
if (wasPlaying) audio.play().catch(() => {});
}, { once: true });
langOpts.forEach(o => o.classList.remove('active'));
opt.classList.add('active');
langBtn.innerHTML = flag
? `<span class="fi fi-${flag}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>`
: langBtn.innerHTML;
langPopup.classList.remove('open');
// Update title flag
const titleFlagEl = document.getElementById('videoTitleFlag');
if (titleFlagEl && flag) {
titleFlagEl.className = 'fi fi-' + flag;
titleFlagEl.style.display = 'inline-block';
}
// Update page title text
const titleTextEl = document.getElementById('videoTitleText');
const trackTitle = opt.dataset.langTitle || '';
const primaryTitle = titleTextEl ? (titleTextEl.dataset.primaryTitle || titleTextEl.textContent) : '';
const displayTitle = trackTitle || primaryTitle;
if (titleTextEl) titleTextEl.textContent = displayTitle;
document.title = displayTitle + ' | {{ config("app.name") }}';
// Update description box — fall back to primary track description if track has none
const primaryOptEl = langPopup.querySelector('.ytp-lang-option[data-lang-id="0"]');
const primaryDesc = primaryOptEl ? (primaryOptEl.dataset.langDescription || '') : '';
const desc = opt.dataset.langDescription || primaryDesc;
_updateDescriptionBox(desc);
// Update download links
const dlUrl = opt.dataset.langDlUrl;
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; });
});
});
}
// ── Description box updater ──────────────────────────────────
function _updateDescriptionBox(text) {
text = (text || '').trim();
const SHORT_LEN = 200;
const needsExpand = text.length > SHORT_LEN;
const short = needsExpand ? text.slice(0, SHORT_LEN) + '…' : text;
let shortEl = document.getElementById('vdbDescShort');
const fullEl = document.getElementById('vdbDescFull');
// If the description elements don't exist yet, inject them into vdb-about
if (!shortEl) {
const aboutPanel = document.getElementById('vdb-about');
if (aboutPanel) {
// Remove existing "No description" placeholder if present
const ph = aboutPanel.querySelector('p[style*="text-secondary"]');
if (ph) ph.style.display = 'none';
shortEl = document.createElement('div');
shortEl.id = 'vdbDescShort';
shortEl.className = 'vdb-desc-text';
aboutPanel.appendChild(shortEl);
}
}
if (!shortEl) return;
if (!text) {
shortEl.style.display = 'none';
if (fullEl) fullEl.style.display = 'none';
const moreBtn2 = shortEl.nextElementSibling;
if (moreBtn2 && moreBtn2.classList.contains('vdb-show-more')) moreBtn2.style.display = 'none';
return;
}
// Always reset to the collapsed view on a language switch
shortEl.textContent = needsExpand ? short : text;
shortEl.style.display = '';
// Ensure the full-text element exists when expansion is needed
let full = fullEl;
if (needsExpand && !full) {
full = document.createElement('div');
full.id = 'vdbDescFull';
full.className = 'vdb-desc-text';
full.style.display = 'none';
shortEl.insertAdjacentElement('afterend', full);
}
if (full) {
full.textContent = text;
full.style.display = 'none';
}
// Locate (or create) the show-more button — it is NOT necessarily shortEl's
// direct sibling because #vdbDescFull sits between them, so search the panel.
const aboutPanel = document.getElementById('vdb-about');
let moreBtn = aboutPanel ? aboutPanel.querySelector('.vdb-show-more') : null;
if (needsExpand) {
if (!moreBtn) {
moreBtn = document.createElement('button');
moreBtn.className = 'vdb-show-more';
moreBtn.onclick = function() {
const f = document.getElementById('vdbDescFull');
const collapsed = !f || f.style.display === 'none';
if (f) f.style.display = collapsed ? 'block' : 'none';
shortEl.style.display = collapsed ? 'none' : '';
moreBtn.textContent = collapsed ? 'Show less' : 'Show more';
};
(full || shortEl).insertAdjacentElement('afterend', moreBtn);
}
moreBtn.textContent = 'Show more';
moreBtn.style.display = '';
} else if (moreBtn) {
moreBtn.style.display = 'none';
}
}
// ── Fullscreen ───────────────────────────────────────────────
fsBtn.addEventListener('click', e => {
e.stopPropagation();
@@ -558,7 +781,7 @@ audio.addEventListener('seeked', () => {
}
});
audio.addEventListener('timeupdate', updateProgress);
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); });
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); updateProgress(); });
audio.addEventListener('ended', () => { releaseWakeLock(); if (window._plOnTrackEnd) { window._plOnTrackEnd(); } else if (NEXT_URL) { window.location.href = NEXT_URL; } });
audio.addEventListener('volumechange', updateVolumeIcon);
@@ -566,18 +789,19 @@ audio.addEventListener('volumechange', updateVolumeIcon);
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';
if (!loopBtn) return;
loopBtn.classList.toggle('is-on', isLooping);
loopBtn.title = isLooping ? 'Loop: On' : 'Loop';
}
applyLoopState();
if (loopRow) {
loopRow.addEventListener('click', e => {
if (loopBtn) {
loopBtn.addEventListener('click', e => {
e.stopPropagation();
isLooping = !isLooping;
audio.loop = isLooping;
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
applyLoopState();
showControls();
});
}
@@ -828,10 +1052,132 @@ window._audioPlayerUpdate = function(d) {
if (coverEl) { coverEl.style.display = ''; coverEl.src = d.cover_url || ''; }
}
// Update page title
var titleEl = document.querySelector('.video-title span');
if (titleEl) titleEl.textContent = d.title || '';
// Rebuild language track popup from player-data response
(function() {
var langWrap = document.getElementById('ytpLangWrap');
var langPopup = document.getElementById('ytpLangPopup');
if (!langWrap || !langPopup || !langBtn) return;
function flagHtml(flag, size) {
var s = size === 'sm' ? 'width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;'
: 'width:22px;height:16px;border-radius:2px;display:inline-block;';
return '<span class="fi fi-' + (flag || 'xx') + '" style="' + s + '"></span>';
}
// Build combined track list: primary + deduped extras
var primaryLangNew = d.language || null;
var primaryFlagFromApi = d.language_flag || null;
var allTracks = [];
if (primaryLangNew) {
allTracks.push({
id: 0,
language: primaryLangNew,
label: primaryLangNew.toUpperCase(),
flag: primaryFlagFromApi,
stream_url: d.stream_url,
title: d.title || '',
description: d.description || '',
dl_url: d.download_url || d.stream_url
});
} else {
allTracks.push({ id: 0, language: '', label: 'Default', flag: null, stream_url: d.stream_url, title: d.title || '', description: d.description || '', dl_url: d.download_url || d.stream_url });
}
(d.audio_tracks || []).forEach(function(t) {
allTracks.push({
id: t.id,
language: t.language,
label: t.label,
flag: t.flag,
stream_url: t.stream_url,
title: t.title || '',
description: t.description || '',
dl_url: t.dl_url || (t.stream_url + '?download=1')
});
});
var hasMultiple = allTracks.length > 1;
var primaryFlagVal = allTracks[0] ? allTracks[0].flag : null;
// Show the button whenever the primary language has a flag; hide only when no flag at all
langWrap.style.display = primaryFlagVal ? '' : 'none';
langBtn.innerHTML = flagHtml(primaryFlagVal, 'lg');
if (!hasMultiple) {
// Single track — flag is read-only language indicator, no popup needed
langPopup.innerHTML = '';
return;
}
// Rebuild popup HTML
var html = '<div class="ytp-lang-popup-hdr">Language</div>';
allTracks.forEach(function(t, i) {
var safeDesc = (t.description || '').replace(/"/g, '&quot;');
var safeTitle = (t.title || '').replace(/"/g, '&quot;');
html += '<div class="ytp-lang-option' + (i === 0 ? ' active' : '') + '"'
+ ' data-lang-id="' + t.id + '"'
+ ' data-lang-url="' + t.stream_url + '"'
+ ' data-lang-label="' + t.label + '"'
+ ' data-lang-flag="' + (t.flag || '') + '"'
+ ' data-lang-title="' + safeTitle + '"'
+ ' data-lang-description="' + safeDesc + '"'
+ ' data-lang-dl-url="' + (t.dl_url || t.stream_url) + '">'
+ flagHtml(t.flag, 'sm')
+ '<span class="ytp-lang-opt-label">' + t.label + '</span>'
+ '<svg class="ytp-lang-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>'
+ '</div>';
});
langPopup.innerHTML = html;
// Bind click handlers on rebuilt options
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(opt) {
opt.addEventListener('click', function() {
var url = opt.dataset.langUrl;
var flag = opt.dataset.langFlag;
var curTime = audio.currentTime;
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
opt.classList.add('active');
langPopup.classList.remove('open');
var _vol = audio.volume, _muted = audio.muted;
audio.src = url; audio.load();
audio.volume = _vol; audio.muted = _muted;
audio.addEventListener('loadedmetadata', function() {
audio.currentTime = curTime; audio.play().catch(function(){});
}, { once: true });
langBtn.innerHTML = flagHtml(flag, 'lg');
// Update title flag
var tfe = document.getElementById('videoTitleFlag');
if (tfe && flag) { tfe.className = 'fi fi-' + flag; tfe.style.display = 'inline-block'; }
// Update page title text
var tte2 = document.getElementById('videoTitleText');
var tTitle2 = opt.dataset.langTitle || '';
var pTitle2 = tte2 ? (tte2.dataset.primaryTitle || tte2.textContent) : '';
var dTitle2 = tTitle2 || pTitle2;
if (tte2) tte2.textContent = dTitle2;
document.title = dTitle2 + ' | {{ config("app.name") }}';
// Update description — fall back to primary track description if track has none
var primaryDescFallback = allTracks[0] ? (allTracks[0].description || '') : '';
_updateDescriptionBox(opt.dataset.langDescription || primaryDescFallback);
// Update download links
var dlUrl = opt.dataset.langDlUrl;
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(function(l){ l.href = dlUrl; });
});
});
// Reset button to primary track flag
langBtn.innerHTML = flagHtml(allTracks[0] ? allTracks[0].flag : null, 'lg');
}());
// Update page title + title flag
var titleEl = document.getElementById('videoTitleText') || document.querySelector('.video-title span:not(#videoTitleFlag)');
if (titleEl) { titleEl.textContent = d.title || ''; titleEl.dataset.primaryTitle = d.title || ''; }
document.title = (d.title || '') + ' | {{ config("app.name") }}';
var titleFlagEl2 = document.getElementById('videoTitleFlag');
if (titleFlagEl2) {
var pf = d.language_flag || null;
if (pf) { titleFlagEl2.className = 'fi fi-' + pf; titleFlagEl2.style.display = 'inline-block'; }
else { titleFlagEl2.style.display = 'none'; }
}
// Update description to primary track's description
_updateDescriptionBox(d.description || '');
// Reset progress bar
if (played) played.style.width = '0%';
@@ -73,7 +73,11 @@ function switchVdbTab(panelId, btn) {
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();
if (panelId === 'vdb-insights') {
const panel = document.getElementById('vdb-insights');
const currentUrl = panel && panel.dataset.insightsBase;
if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights();
}
}
function toggleVdbDesc(btn) {
const s = document.getElementById('vdbDescShort'), f = document.getElementById('vdbDescFull');
+33 -8
View File
@@ -747,18 +747,29 @@
var recTransiting = false;
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') === '1';
// persists across sidebar swaps so already-autoplayed videos are not repeated
window._recWatched = window._recWatched || new Set();
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
function recNextUnwatched() {
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
for (var i = 0; i < cards.length; i++) {
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
}
return null;
}
function recRender() {
var btn = document.getElementById('recAutoplayBtn');
if (!btn) return;
btn.classList.toggle('pl-ctrl-active', recAutoplay);
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
// highlight the next-up card
// highlight the first unwatched card as "next up"
var cards = document.querySelectorAll('#recList .sidebar-video-card');
cards.forEach(function(c,i){ c.style.opacity = recAutoplay && i===0 ? '1' : ''; });
var first = cards[0];
if(first) first.classList.toggle('rec-next-up', recAutoplay);
var nextUp = recNextUnwatched() || cards[0];
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
}
window.recToggleAutoplay = function() {
@@ -846,13 +857,27 @@
} catch(e){ console.warn('recSwapContent', e); }
}
window.recGoTo = function(url) { if(url) recTransitionTo(url); };
// user manually picks a video — reset watch history so next autoplay starts fresh
window.recGoTo = function(url) {
if (!url) return;
window._recWatched = new Set();
recTransitionTo(url);
};
// autoplay: when video ends, play the first rec card
// autoplay: mark current video watched, then play first unwatched rec card
window._plOnVideoEnd = function() {
if (!recAutoplay) return;
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
window._recWatched.add(window.location.href);
var next = recNextUnwatched();
if (next) {
recTransitionTo(next.dataset.recUrl);
} else {
// all recommendations watched — notify and restart the cycle
showToast('All recommendations watched — starting over', 'info');
window._recWatched = new Set();
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
}
};
window.addEventListener('popstate', function(e){ if(e.state&&e.state.recUrl) recTransitionTo(e.state.recUrl,false); });
+43 -17
View File
@@ -842,9 +842,8 @@
margin-top: 2px;
}
.fighter-country-flag {
font-size: 0.9rem;
}
.fighter-country-flag { display: inline-flex; align-items: center; }
.fighter-country-flag .fi { width: 22px; height: 16px; border-radius: 2px; }
.match-center {
display: flex;
@@ -1893,7 +1892,7 @@
$blueFighter ??
(object) [
'name' => 'Sami Al Manea',
'flag' => '🇧🇭',
'flag' => 'bh',
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_5.png?v=1773157950',
'team' => 'Emperor TaeKwonDo Academy',
'country' => 'Bahrain',
@@ -1905,7 +1904,7 @@
$redFighter ??
(object) [
'name' => 'Hassan Al Shallan',
'flag' => '🇧🇭',
'flag' => 'bh',
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_11.png?t=1773268056907',
'team' => 'Legend TaeKwonDo Academy',
'country' => 'Bahrain',
@@ -1928,7 +1927,7 @@
$referee ??
(object) [
'name' => 'Ghassan Yusuf',
'flag' => '🇧🇭',
'flag' => 'bh',
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_8.png?t=1773267305519',
'member_id' => 4,
];
@@ -1978,7 +1977,7 @@
<i class="bi bi-trophy trophy-icon" title="Match Type"></i>
<span>{{ $video->title }} –</span>
<span style="color: #2563eb; font-weight: bold">
{{ $blueFighter->flag ?? '🇧🇭' }}
<span class="fi fi-{{ $blueFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>
<a href="https://takeone-dev.innovator.bh/member/{{ $blueFighter->member_id ?? 5 }}"
title="View {{ $blueFighter->name ?? 'Sami Al Manea' }}'s profile">
{{ $blueFighter->name ?? 'Sami Al Manea' }}
@@ -1986,7 +1985,7 @@
</span>
<span>vs</span>
<span style="color: #ef4444; font-weight: bold">
{{ $redFighter->flag ?? '🇧🇭' }}
<span class="fi fi-{{ $redFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>
<a href="https://takeone-dev.innovator.bh/member/{{ $redFighter->member_id ?? 11 }}"
title="View {{ $redFighter->name ?? 'Hassan Al Shallan' }}'s profile">
{{ $redFighter->name ?? 'Hassan Al Shallan' }}
@@ -2009,7 +2008,7 @@
<span style="color: var(--text-secondary);">•</span>
<span
style="color: #ffd900; font-weight: bold; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 2px;">
{{ $referee->flag ?? '🇧🇭' }} {{ $referee->name ?? 'Ghassan Yusuf' }}
<span class="fi fi-{{ $referee->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span> {{ $referee->name ?? 'Ghassan Yusuf' }}
</span>
<span
style="color: var(--text-secondary); font-weight: bold; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 2px;">
@@ -2090,7 +2089,7 @@
</a>
</div>
<div class="fighter-country">
<span class="fighter-country-flag">{{ $blueFighter->flag ?? '🇧🇭' }}</span>
<span class="fighter-country-flag"><span class="fi fi-{{ $blueFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span></span>
<span>{{ $blueFighter->country ?? 'Bahrain' }}</span>
</div>
</div>
@@ -2165,7 +2164,7 @@
</a>
</div>
<div class="fighter-country">
<span class="fighter-country-flag">{{ $redFighter->flag ?? '🇧🇭' }}</span>
<span class="fighter-country-flag"><span class="fi fi-{{ $redFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span></span>
<span>{{ $redFighter->country ?? 'Bahrain' }}</span>
</div>
</div>
@@ -2821,17 +2820,29 @@
var recTransiting = false;
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
// persists across sidebar swaps so already-autoplayed videos are not repeated
window._recWatched = window._recWatched || new Set();
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
function recNextUnwatched() {
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
for (var i = 0; i < cards.length; i++) {
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
}
return null;
}
function recRender() {
var btn = document.getElementById('recAutoplayBtn');
if (!btn) return;
btn.classList.toggle('pl-ctrl-active', recAutoplay);
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
// highlight the first unwatched card as "next up"
var cards = document.querySelectorAll('#recList .sidebar-video-card');
cards.forEach(function(c,i){ c.style.opacity = recAutoplay && i===0 ? '1' : ''; });
var first = cards[0];
if(first) first.classList.toggle('rec-next-up', recAutoplay);
var nextUp = recNextUnwatched() || cards[0];
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
}
window.recToggleAutoplay = function() {
@@ -2907,12 +2918,27 @@
} catch(e){ console.warn('recSwapContent', e); }
}
window.recGoTo = function(url) { if(url) recTransitionTo(url); };
// user manually picks a video — reset watch history so next autoplay starts fresh
window.recGoTo = function(url) {
if (!url) return;
window._recWatched = new Set();
recTransitionTo(url);
};
// autoplay: mark current video watched, then play first unwatched rec card
window._plOnVideoEnd = function() {
if (!recAutoplay) return;
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
window._recWatched.add(window.location.href);
var next = recNextUnwatched();
if (next) {
recTransitionTo(next.dataset.recUrl);
} else {
// all recommendations watched — notify and restart the cycle
showToast('All recommendations watched — starting over', 'info');
window._recWatched = new Set();
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
}
};
window.addEventListener('popstate', function(e){ if(e.state&&e.state.recUrl) recTransitionTo(e.state.recUrl,false); });
+48 -7
View File
@@ -451,9 +451,11 @@
@endif
<!-- Video Title with Music Note Icon (Music Type) -->
@php $titleLangFlag = \App\Data\Languages::flag($video->language); @endphp
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
<i class="bi bi-music-note" style="color: #ef4444;"></i>
<span>{{ $video->title }}</span>
<i class="bi bi-music-note" style="color: #ef4444; flex-shrink:0;"></i>
<span class="fi fi-{{ $titleLangFlag ?: 'xx' }}" id="videoTitleFlag" style="width:22px;height:16px;border-radius:2px;display:{{ $titleLangFlag ? 'inline-block' : 'none' }};flex-shrink:0;"></span>
<span id="videoTitleText" data-primary-title="{{ $video->title }}">{{ $video->title }}</span>
</h1>
<!-- Stats Row - Hidden, shown in description box -->
@@ -557,7 +559,11 @@
// swap audio src (keeps browser autoplay permission)
var audio = document.getElementById('audioEl');
if (audio) { audio.src=d.stream_url; audio.load(); }
if (audio) {
var _savedVol = audio.volume; var _savedMuted = audio.muted;
audio.src = d.stream_url; audio.load();
audio.volume = _savedVol; audio.muted = _savedMuted;
}
// update cover / slideshow / title / progress via shared hook
if (window._audioPlayerUpdate) window._audioPlayerUpdate(d);
@@ -731,11 +737,27 @@
var recTransiting = false;
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
// persists across sidebar swaps so already-autoplayed tracks are not repeated
window._recWatched = window._recWatched || new Set();
function recNextUnwatched() {
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
for (var i = 0; i < cards.length; i++) {
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
}
return null;
}
function recRender() {
var btn = document.getElementById('recAutoplayBtn');
if (!btn) return;
btn.classList.toggle('pl-ctrl-active', recAutoplay);
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
// highlight the first unwatched card as "next up"
var cards = document.querySelectorAll('#recList .sidebar-video-card');
var nextUp = recNextUnwatched() || cards[0];
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
}
async function recTransitionTo(url, pushHist) {
@@ -748,7 +770,11 @@
var d = await resp.json();
var audio = document.getElementById('audioEl');
if (audio) { audio.src = d.stream_url; audio.load(); }
if (audio) {
var _savedVol = audio.volume; var _savedMuted = audio.muted;
audio.src = d.stream_url; audio.load();
audio.volume = _savedVol; audio.muted = _savedMuted;
}
if (window._audioPlayerUpdate) window._audioPlayerUpdate(d);
@@ -800,17 +826,32 @@
} catch(e) { console.warn('recSwapContent', e); }
}
window.recGoTo = function(url) { if (url) recTransitionTo(url); };
// user manually picks a track — reset watch history so next autoplay starts fresh
window.recGoTo = function(url) {
if (!url) return;
window._recWatched = new Set();
recTransitionTo(url);
};
window.recToggleAutoplay = function() {
recAutoplay = !recAutoplay;
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
recRender();
};
// autoplay: mark current track watched, then play first unwatched rec card
window._plOnTrackEnd = function() {
if (!recAutoplay) return;
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
window._recWatched.add(window.location.href);
var next = recNextUnwatched();
if (next) {
recTransitionTo(next.dataset.recUrl);
} else {
// all recommendations watched — notify and restart the cycle
showToast('All recommendations watched — starting over', 'info');
window._recWatched = new Set();
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
if (first) recTransitionTo(first.dataset.recUrl);
}
};
window.addEventListener('popstate', function(e) { if (e.state && e.state.url) recTransitionTo(e.state.url, false); });