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

+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;