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) <noreply@anthropic.com>
377 lines
21 KiB
PHP
377 lines
21 KiB
PHP
@props([
|
|
'prefix' => 't1',
|
|
'isPrimary' => false,
|
|
'languageName' => 'primary_language',
|
|
'languageId' => 'edit_primary_language',
|
|
'titleName' => 'title',
|
|
'titleId' => 'edit-track1-title',
|
|
'descName' => 'description',
|
|
'descId' => 'edit-track1-desc',
|
|
'videoFileInputId' => 'edit-video-file',
|
|
'embedAudioInput' => false,
|
|
'audioInputName' => '',
|
|
])
|
|
|
|
@php
|
|
$p = $prefix;
|
|
$langOptions = \App\Data\Languages::forLanguage();
|
|
$musicDisplay = $isPrimary ? 'none' : 'flex';
|
|
@endphp
|
|
|
|
{{-- ── CSD shared CSS ────────────────────────────────────────────────────────── --}}
|
|
@once('csd-styles')
|
|
<style>
|
|
.csd-wrap { position: relative; }
|
|
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
|
|
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
|
|
.csd-btn {
|
|
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
|
|
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
|
|
font-size: 14px; font-family: inherit; line-height: 1.4; cursor: pointer;
|
|
text-align: left; transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
|
|
}
|
|
.csd-btn:hover, .csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
|
|
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
|
|
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
|
|
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.csd-val.ph { color: var(--text-secondary, #aaa); }
|
|
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
|
|
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
|
|
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
|
|
.csd-panel {
|
|
position: fixed; z-index: 1080; min-width: 220px;
|
|
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
|
|
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); overflow: hidden;
|
|
}
|
|
.csd-srch { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #303030); }
|
|
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
|
|
.csd-sinput { flex: 1; background: none; border: none; outline: none; color: var(--text-primary, #f1f1f1); font-size: 13px; font-family: inherit; }
|
|
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
|
|
.csd-list { list-style: none; margin: 0; padding: 4px 0; max-height: 240px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent; }
|
|
.csd-list::-webkit-scrollbar { width: 4px; }
|
|
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
|
|
.csd-opt { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1); transition: background .1s; outline: none; }
|
|
.csd-opt:hover, .csd-opt:focus { background: rgba(255,255,255,.07); }
|
|
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
|
|
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
|
|
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
|
|
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
|
|
</style>
|
|
@endonce
|
|
|
|
@once('lsd-flag-styles')
|
|
<style>
|
|
.lsd-flag { display: inline-block; width: 20px; height: 15px; border-radius: 2px; flex-shrink: 0; }
|
|
.csd-btn .lsd-flag { width: 22px; height: 16px; }
|
|
</style>
|
|
@endonce
|
|
|
|
@once('csd-script')
|
|
<script>
|
|
(function () {
|
|
if (window.CSD) return;
|
|
window.CSD = class {
|
|
constructor(id) {
|
|
this.root = document.getElementById(id);
|
|
this.btn = this.root.querySelector('.csd-btn');
|
|
this.panel = this.root.querySelector('.csd-panel');
|
|
this.sinput = this.root.querySelector('.csd-sinput');
|
|
this.list = this.root.querySelector('.csd-list');
|
|
this.hidden = this.root.querySelector('input[type=hidden]');
|
|
this.icoEl = this.btn.querySelector('.csd-ico');
|
|
this.valEl = this.btn.querySelector('.csd-val');
|
|
this.subEl = this.btn.querySelector('.csd-sub');
|
|
this.emptyEl= this.root.querySelector('.csd-empty');
|
|
this._bind();
|
|
}
|
|
_bind() {
|
|
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
|
|
this.sinput.addEventListener('input', () => this._filter());
|
|
this.list.addEventListener('click', e => { const o = e.target.closest('.csd-opt'); if (o) this._pick(o); });
|
|
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
|
|
this.btn.addEventListener('keydown', e => { if (e.key==='Enter'||e.key===' ') { e.preventDefault(); this.toggle(); } });
|
|
this.sinput.addEventListener('keydown', e => {
|
|
if (e.key==='Escape') { this.close(); this.btn.focus(); }
|
|
if (e.key==='ArrowDown') { e.preventDefault(); this._move(0); }
|
|
if (e.key==='Enter') { e.preventDefault(); }
|
|
});
|
|
this.list.addEventListener('keydown', e => {
|
|
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
|
|
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
|
|
if (e.key==='ArrowDown') { e.preventDefault(); this._move(i+1); }
|
|
if (e.key==='ArrowUp') { e.preventDefault(); i>0 ? this._move(i-1) : this.sinput.focus(); }
|
|
if (e.key==='Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
|
|
if (e.key==='Escape') { this.close(); this.btn.focus(); }
|
|
});
|
|
}
|
|
toggle() { this.panel.hidden ? this.open() : this.close(); }
|
|
open() {
|
|
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
|
|
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
|
|
this.panel.hidden = false;
|
|
this.btn.setAttribute('aria-expanded','true');
|
|
this.sinput.value = '';
|
|
this._filter();
|
|
requestAnimationFrame(() => this.sinput.focus());
|
|
const r = this.btn.getBoundingClientRect();
|
|
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
|
|
this.panel.style.left = r.left + 'px';
|
|
this.panel.style.width = Math.max(r.width, 220) + 'px';
|
|
if (goUp) {
|
|
this.panel.style.top = '';
|
|
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
|
|
} else {
|
|
this.panel.style.top = (r.bottom + 4) + 'px';
|
|
this.panel.style.bottom = '';
|
|
}
|
|
}
|
|
close() { this.panel.hidden=true; this.btn.setAttribute('aria-expanded','false'); }
|
|
_filter() {
|
|
const q = this.sinput.value.toLowerCase(); let n=0;
|
|
this.list.querySelectorAll('.csd-opt').forEach(o => {
|
|
const show = o.dataset.s.includes(q);
|
|
o.style.display = show ? '' : 'none';
|
|
if (show) n++;
|
|
});
|
|
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
|
|
}
|
|
_move(i) { const all=[...this.list.querySelectorAll('.csd-opt:not([hidden])')]; if(all[i]){all[i].tabIndex=0;all[i].focus();} }
|
|
_pick(opt) {
|
|
this.hidden.value = opt.dataset.v;
|
|
this.icoEl.innerHTML = opt.querySelector('.csd-opt-ico').innerHTML;
|
|
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
|
|
this.valEl.classList.remove('ph');
|
|
if (this.subEl) { const s=opt.querySelector('.csd-opt-sub'); this.subEl.textContent=s?s.textContent:''; }
|
|
this.list.querySelectorAll('.csd-opt').forEach(o => o.setAttribute('aria-selected', o===opt?'true':'false'));
|
|
this.close(); this.btn.focus();
|
|
this.hidden.dispatchEvent(new Event('change',{bubbles:true}));
|
|
}
|
|
};
|
|
}());
|
|
</script>
|
|
@endonce
|
|
|
|
<div class="um-track-form" id="edit-tf-{{ $p }}" style="display:none;">
|
|
|
|
{{-- ── Primary track banner ──────────────────────────────────── --}}
|
|
@if($isPrimary)
|
|
<div class="um-tf-primary-note">
|
|
<i class="bi bi-star-fill"></i>
|
|
<span>Primary Track — this language and title are the default display name</span>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ── Language + Title ──────────────────────────────────────── --}}
|
|
<div class="um-tf-row2">
|
|
<div id="edit-tf-{{ $p }}-lang-wrap">
|
|
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language</label>
|
|
<div class="csd-wrap" id="csd_{{ $p }}">
|
|
<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">
|
|
@foreach($langOptions as $opt)
|
|
<li class="csd-opt" role="option" tabindex="-1"
|
|
data-v="{{ $opt['value'] }}" data-s="{{ $opt['search'] }}" aria-selected="false">
|
|
<span class="csd-opt-ico"><span class="fi fi-{{ $opt['flag'] }} lsd-flag"></span></span>
|
|
<span class="csd-opt-main">{{ $opt['label'] }}</span>
|
|
<span class="csd-opt-sub">{{ $opt['native'] }}</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
<p class="csd-empty" hidden>No results</p>
|
|
</div>
|
|
<input type="hidden" name="{{ $languageName }}" id="{{ $languageId }}">
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
|
Title @if($isPrimary)<span class="um-req">*</span>@else<span class="um-lbl-hint">optional</span>@endif
|
|
</label>
|
|
<input type="text"
|
|
name="{{ $titleName }}"
|
|
id="{{ $titleId }}"
|
|
class="um-input"
|
|
style="font-size:13px;padding:9px 12px;"
|
|
placeholder="Video title…"
|
|
autocomplete="off">
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Description ────────────────────────────────────────────── --}}
|
|
<div class="um-field">
|
|
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description @if(!$isPrimary)<span class="um-lbl-hint">optional</span>@endif</label>
|
|
<textarea name="{{ $descName }}"
|
|
id="{{ $descId }}"
|
|
class="um-input um-textarea"
|
|
rows="3"
|
|
style="font-size:13px;padding:9px 12px;"
|
|
placeholder="Tell viewers about this content…"></textarea>
|
|
</div>
|
|
|
|
{{-- ── Video file + Thumbnail (video / match mode, primary only) ── --}}
|
|
<div style="display:flex;gap:12px;align-items:stretch;">
|
|
|
|
<div class="um-field um-field-col" id="edit-tf-{{ $p }}-video-zone"
|
|
style="display:none;flex:1;min-width:0;margin-bottom:0;">
|
|
<label class="um-lbl">
|
|
<i class="bi bi-film"></i> Replace Video
|
|
<span class="um-lbl-hint">optional</span>
|
|
</label>
|
|
<div id="edit-{{ $p }}-dropzone" class="um-dropzone um-zone-fill"
|
|
onclick="if(!event.target.closest('.um-x-btn')) {{ $videoFileInputId }}.click()"
|
|
ondragover="event.preventDefault();this.classList.add('dragover')"
|
|
ondragleave="this.classList.remove('dragover')"
|
|
ondrop="event.preventDefault();this.classList.remove('dragover');
|
|
if(event.dataTransfer.files.length){
|
|
editVideoFile.files=event.dataTransfer.files;
|
|
editHandleVideoSelect(editVideoFile);
|
|
}">
|
|
<div id="edit-{{ $p }}-dz-idle" class="um-dz-idle">
|
|
<div class="um-dz-ring"><i class="bi bi-cloud-arrow-up"></i></div>
|
|
<p class="um-dz-title">Drop to replace</p>
|
|
<p class="um-dz-sub">or click to browse</p>
|
|
<div class="um-dz-formats">
|
|
<span>MP4</span><span>MOV</span><span>MKV</span><span>AVI</span><span>WebM</span>
|
|
</div>
|
|
</div>
|
|
<div id="edit-{{ $p }}-file-info" class="um-file-card" style="display:none;">
|
|
<div class="um-file-icon-wrap"><i class="bi bi-film"></i></div>
|
|
<div class="um-file-meta">
|
|
<span class="um-file-name" id="edit-{{ $p }}-filename"></span>
|
|
<span class="um-file-size" id="edit-{{ $p }}-filesize"></span>
|
|
</div>
|
|
<button type="button" class="um-x-btn" onclick="editRemoveVideo(event)" title="Remove">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="um-field um-field-col" id="edit-tf-{{ $p }}-thumb-wrap"
|
|
style="display:none;flex:1;min-width:0;margin-bottom:0;">
|
|
<label class="um-lbl">
|
|
<i class="bi bi-card-image"></i>
|
|
<span>Thumbnail</span>
|
|
<span class="um-lbl-hint">16:9</span>
|
|
</label>
|
|
<div id="edit-{{ $p }}-thumbnail-dropzone" class="um-thumb-zone um-zone-fill">
|
|
<input type="file" name="thumbnail" id="edit-{{ $p }}-thumbnail-input"
|
|
accept="image/*" style="display:none;"
|
|
onchange="editHandleThumbnail(this, '{{ $p }}')">
|
|
<div id="edit-{{ $p }}-thumbnail-ph" class="um-thumb-ph">
|
|
<div class="um-dz-ring"><i class="bi bi-card-image"></i></div>
|
|
<p class="um-thumb-ph-title">Click to upload</p>
|
|
<p class="um-thumb-ph-sub">16:9 recommended</p>
|
|
</div>
|
|
<div id="edit-{{ $p }}-thumbnail-info" class="um-thumb-info" style="display:none;">
|
|
<div class="um-thumb-prev">
|
|
<img id="edit-{{ $p }}-thumbnail-preview" src="" alt="">
|
|
</div>
|
|
<div class="um-thumb-meta">
|
|
<span class="um-thumb-name" id="edit-{{ $p }}-thumbnail-fname"></span>
|
|
<span class="um-thumb-size" id="edit-{{ $p }}-thumbnail-fsize"></span>
|
|
</div>
|
|
<button type="button" class="btn-remove-file um-x-btn"
|
|
onclick="editRemoveThumbnail(event, '{{ $p }}')">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>{{-- /video+thumb row --}}
|
|
|
|
{{-- ── Audio file + Cover Slides ──────────────────────────────── --}}
|
|
<div id="edit-tf-{{ $p }}-music-pair"
|
|
style="display:{{ $musicDisplay }};gap:12px;align-items:stretch;margin-bottom:14px;">
|
|
|
|
<div class="um-field-col" id="edit-tf-{{ $p }}-audio-zone"
|
|
style="flex:1;min-width:0;margin-bottom:0;">
|
|
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
|
<i class="bi bi-music-note-beamed"></i> Audio File
|
|
<span class="um-lbl-hint">optional — replace</span>
|
|
</label>
|
|
<div id="edit-tf-{{ $p }}-audio-box" class="um-slides-zone"
|
|
onclick="@if($embedAudioInput)document.getElementById('edit-{{ $p }}-audio-input').click()@else{{ $videoFileInputId }}.click()@endif"
|
|
style="cursor:pointer;flex:1;">
|
|
<div class="um-slides-ph" id="edit-{{ $p }}-audio-ph">
|
|
<i class="bi bi-music-note-beamed"></i>
|
|
<span id="edit-{{ $p }}-fname">Keep existing / choose new…</span>
|
|
</div>
|
|
@if($embedAudioInput)
|
|
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
|
|
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
|
onchange="document.getElementById('edit-{{ $p }}-fname').textContent=this.files[0]?.name||'Keep existing / choose new…'">
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<div class="um-field-col" id="edit-tf-{{ $p }}-slides-wrap"
|
|
style="flex:1;min-width:0;margin-bottom:0;">
|
|
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
|
|
Cover Slides <span class="um-lbl-hint">drag to reorder</span>
|
|
</label>
|
|
{{-- name="slides_order" only on primary; secondary tracks are managed by JS and must not pollute this field --}}
|
|
<input type="hidden" name="{{ $isPrimary ? 'slides_order' : '' }}" id="edit-{{ $p }}-slides-order" value="[]">
|
|
<div id="edit-{{ $p }}-slides-zone" class="um-slides-zone"
|
|
onclick="editSlidesZoneClick(event,'{{ $p }}')"
|
|
ondragover="editSlidesZoneDragover(event,'{{ $p }}')"
|
|
ondragleave="editSlidesZoneDragleave('{{ $p }}')"
|
|
ondrop="editSlidesZoneDrop(event,'{{ $p }}')">
|
|
{{-- No name attr — JS uses _editSlidesData state and appends files manually on submit --}}
|
|
<input type="file" accept="image/*" multiple
|
|
style="display:none" id="edit-{{ $p }}-slides-input"
|
|
onchange="editHandleSlides(this.files,'{{ $p }}')">
|
|
<div class="um-slides-ph" id="edit-{{ $p }}-slides-ph">
|
|
<i class="bi bi-images"></i>
|
|
<span>Click or drag to add images</span>
|
|
</div>
|
|
<div id="edit-{{ $p }}-slides-preview"
|
|
style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
|
|
<div id="edit-{{ $p }}-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="edit-{{ $p }}-slides-count"
|
|
style="font-size:12px;color:var(--text-secondary);"></span>
|
|
<button type="button"
|
|
onclick="editClearSlides(event,'{{ $p }}')"
|
|
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 all
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>{{-- /music pair --}}
|
|
|
|
</div>{{-- /um-track-form --}}
|
|
|
|
{{-- ── Log tracker: report field changes for this track editor instance ──────── --}}
|
|
@if(!str_contains($languageId, '__TPL__'))
|
|
<script>
|
|
(function () {
|
|
var label = '[EditTrack:' + ({{ $isPrimary ? 'true' : 'false' }} ? 'primary' : @json($prefix)) + ']';
|
|
var style = 'color:#a855f7;font-weight:700';
|
|
var langEl = document.getElementById(@json($languageId));
|
|
var titleEl = document.getElementById(@json($titleId));
|
|
var descEl = document.getElementById(@json($descId));
|
|
if (langEl) langEl.addEventListener('change', function () { console.log('%c' + label + ' language →', style, langEl.value); });
|
|
if (titleEl) titleEl.addEventListener('change', function () { console.log('%c' + label + ' title →', style, titleEl.value); });
|
|
if (descEl) descEl.addEventListener('change', function () { console.log('%c' + label + ' description →', style, (descEl.value || '').slice(0, 80)); });
|
|
})();
|
|
</script>
|
|
@endif
|