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:
1 parent
f8d13457fa
commit
66fd78c10f
314 files changed
+16507
-3172
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user