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
@@ -16,7 +16,7 @@
|
||||
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
|
||||
$inputId = $id ?? $name;
|
||||
|
||||
$selFlag = '🌐';
|
||||
$selFlag = '';
|
||||
$selLabel = $placeholder;
|
||||
if ($value) {
|
||||
foreach ($options as $opt) {
|
||||
@@ -47,14 +47,15 @@
|
||||
}
|
||||
.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-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; display: flex; align-items: center; }
|
||||
.csd-ico .fi, .csd-opt-ico .fi { width: 22px; height: 16px; border-radius: 2px; display: inline-block; flex-shrink: 0; }
|
||||
.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: 1060; min-width: 220px;
|
||||
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;
|
||||
}
|
||||
@@ -161,7 +162,7 @@
|
||||
_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.textContent = opt.querySelector('.csd-opt-ico').textContent;
|
||||
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:''; }
|
||||
@@ -189,7 +190,7 @@
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-label="{{ $label ?? 'Select country' }}">
|
||||
<span class="csd-ico">{{ $selFlag }}</span>
|
||||
<span class="csd-ico"><span class="fi fi-{{ $selFlag ?: 'xx' }}"></span></span>
|
||||
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
|
||||
<i class="bi bi-chevron-down csd-arr"></i>
|
||||
</button>
|
||||
@@ -207,7 +208,7 @@
|
||||
data-v="{{ $opt['value'] }}"
|
||||
data-s="{{ $opt['search'] }}"
|
||||
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
|
||||
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
|
||||
<span class="csd-opt-ico"><span class="fi fi-{{ $opt['flag'] }}"></span></span>
|
||||
<span class="csd-opt-main">{{ $opt['label'] }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
@props([
|
||||
'name' => '',
|
||||
'id' => null,
|
||||
'value' => null,
|
||||
'label' => null,
|
||||
'placeholder' => 'Select language',
|
||||
'required' => false,
|
||||
'class' => '',
|
||||
'style' => '',
|
||||
])
|
||||
|
||||
@php
|
||||
use App\Data\Languages;
|
||||
$options = Languages::forLanguage();
|
||||
$uid = 'lsd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
|
||||
$inputId = $id ?? $name;
|
||||
|
||||
$selFlag = null;
|
||||
$selLabel = $placeholder;
|
||||
if ($value) {
|
||||
foreach ($options as $opt) {
|
||||
if ($opt['value'] === $value) {
|
||||
$selFlag = $opt['flag'];
|
||||
$selLabel = $opt['label'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$isPlaceholder = !$value;
|
||||
@endphp
|
||||
|
||||
{{-- Shared dropdown styles (same .csd-* rules used by country/timezone/phone selects) --}}
|
||||
@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
|
||||
|
||||
{{-- Language-specific: flag icon sizing --}}
|
||||
@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
|
||||
|
||||
{{-- Shared dropdown JS (same window.CSD class used by country/timezone/phone selects) --}}
|
||||
@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();
|
||||
if (this.root.dataset.geoMap && !this.hidden.value) {
|
||||
this._autoGeo();
|
||||
}
|
||||
}
|
||||
_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(); }
|
||||
});
|
||||
}
|
||||
_autoGeo() {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const map = JSON.parse(this.root.dataset.geoMap);
|
||||
const iso2 = map[tz];
|
||||
if (iso2) {
|
||||
const opt = this.list.querySelector('[data-v="' + iso2 + '"]');
|
||||
if (opt) this._pick(opt);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
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="csd-wrap {{ $class }}" id="{{ $uid }}"
|
||||
@if($style) style="{{ $style }}" @endif>
|
||||
|
||||
@if($label)
|
||||
<label class="csd-lbl" for="{{ $inputId }}">
|
||||
{{ $label }}@if($required)<span class="req">*</span>@endif
|
||||
</label>
|
||||
@endif
|
||||
|
||||
<button type="button"
|
||||
class="csd-btn"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-label="{{ $label ?? 'Select language' }}">
|
||||
<span class="csd-ico">
|
||||
<span class="fi fi-{{ $isPlaceholder ? 'xx' : $selFlag }} lsd-flag"></span>
|
||||
</span>
|
||||
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</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($options as $opt)
|
||||
<li class="csd-opt"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
data-v="{{ $opt['value'] }}"
|
||||
data-s="{{ $opt['search'] }}"
|
||||
aria-selected="{{ $value === $opt['value'] ? 'true' : '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="{{ $name }}"
|
||||
id="{{ $inputId }}"
|
||||
value="{{ $value }}"
|
||||
@if($required) required @endif>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function boot() { new CSD('{{ $uid }}'); }
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
@@ -15,7 +15,7 @@
|
||||
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
|
||||
$inputId = $id ?? $name;
|
||||
|
||||
$selFlag = '🌐';
|
||||
$selFlag = '';
|
||||
$selLabel = $placeholder;
|
||||
$selSub = '';
|
||||
if ($value) {
|
||||
@@ -62,7 +62,8 @@
|
||||
.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-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; display: flex; align-items: center; }
|
||||
.csd-ico .fi, .csd-opt-ico .fi { width: 22px; height: 16px; border-radius: 2px; display: inline-block; flex-shrink: 0; }
|
||||
.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; }
|
||||
@@ -71,7 +72,7 @@
|
||||
|
||||
.csd-panel {
|
||||
position: fixed;
|
||||
z-index: 1060;
|
||||
z-index: 1080;
|
||||
min-width: 220px;
|
||||
background: var(--bg-secondary, #1e1e1e);
|
||||
border: 1px solid var(--border-color, #303030);
|
||||
@@ -225,7 +226,7 @@
|
||||
|
||||
_pick(opt) {
|
||||
this.hidden.value = opt.dataset.v;
|
||||
this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent;
|
||||
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) {
|
||||
@@ -258,7 +259,7 @@
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-label="{{ $label ?? 'Phone code' }}">
|
||||
<span class="csd-ico">{{ $selFlag }}</span>
|
||||
<span class="csd-ico"><span class="fi fi-{{ $selFlag ?: 'xx' }}"></span></span>
|
||||
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
|
||||
@if(!$isPlaceholder && $selSub)
|
||||
<span class="csd-sub">{{ $selSub }}</span>
|
||||
@@ -279,7 +280,7 @@
|
||||
data-v="{{ $opt['value'] }}"
|
||||
data-s="{{ $opt['search'] }}"
|
||||
aria-selected="{{ $value && str_starts_with($opt['value'], $value.'|') ? 'true' : 'false' }}">
|
||||
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
|
||||
<span class="csd-opt-ico"><span class="fi fi-{{ $opt['flag'] }}"></span></span>
|
||||
<span class="csd-opt-main">{{ $opt['label'] }}</span>
|
||||
<span class="csd-opt-sub">{{ $opt['secondary'] }}</span>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
@props(['playlist'])
|
||||
|
||||
@php
|
||||
$pl = $playlist;
|
||||
$firstVid = $pl->videos->first();
|
||||
$plUrl = $firstVid
|
||||
? route('videos.show', $firstVid) . '?playlist=' . $pl->share_token
|
||||
: route('playlists.show', $pl->id);
|
||||
@endphp
|
||||
|
||||
@once
|
||||
<style>
|
||||
.pl-count-badge {
|
||||
@@ -59,10 +67,8 @@
|
||||
</style>
|
||||
@endonce
|
||||
|
||||
@php $pl = $playlist; @endphp
|
||||
|
||||
<div class="yt-video-card">
|
||||
<a href="{{ route('playlists.show', $pl->id) }}">
|
||||
<a href="{{ $plUrl }}">
|
||||
<div class="yt-video-thumb">
|
||||
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||
<div class="pl-count-badge">
|
||||
@@ -94,7 +100,7 @@
|
||||
</div>
|
||||
<div class="yt-video-details">
|
||||
<h3 class="yt-video-title">
|
||||
<a href="{{ route('playlists.show', $pl->id) }}">{{ $pl->name }}</a>
|
||||
<a href="{{ $plUrl }}">{{ $pl->name }}</a>
|
||||
</h3>
|
||||
@if($pl->user)
|
||||
<a href="{{ route('channel', $pl->user->channel) }}" class="yt-channel-name"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
|
||||
$inputId = $id ?? $name;
|
||||
|
||||
$selFlag = '🌐';
|
||||
$selFlag = '';
|
||||
$selLabel = $placeholder;
|
||||
$selSub = '';
|
||||
if ($value) {
|
||||
@@ -45,14 +45,15 @@
|
||||
}
|
||||
.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-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; display: flex; align-items: center; }
|
||||
.csd-ico .fi, .csd-opt-ico .fi { width: 22px; height: 16px; border-radius: 2px; display: inline-block; flex-shrink: 0; }
|
||||
.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: 1060; min-width: 220px;
|
||||
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;
|
||||
}
|
||||
@@ -159,7 +160,7 @@
|
||||
_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.textContent = opt.querySelector('.csd-opt-ico').textContent;
|
||||
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:''; }
|
||||
@@ -185,7 +186,7 @@
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-label="{{ $label ?? 'Select timezone' }}">
|
||||
<span class="csd-ico">{{ $selFlag }}</span>
|
||||
<span class="csd-ico"><span class="fi fi-{{ $selFlag ?: 'xx' }}"></span></span>
|
||||
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
|
||||
@if(!$isPlaceholder && $selSub)
|
||||
<span class="csd-sub">{{ $selSub }}</span>
|
||||
@@ -206,7 +207,7 @@
|
||||
data-v="{{ $opt['value'] }}"
|
||||
data-s="{{ $opt['search'] }}"
|
||||
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
|
||||
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
|
||||
<span class="csd-opt-ico"><span class="fi fi-{{ $opt['flag'] ?: 'xx' }}"></span></span>
|
||||
<span class="csd-opt-main">{{ $opt['label'] }}</span>
|
||||
<span class="csd-opt-sub">{{ $opt['utc'] }}</span>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
@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
|
||||
@@ -215,7 +215,7 @@
|
||||
@endif
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ route('videos.downloadMp3', $video) }}"
|
||||
<a class="dropdown-item ytp-mp3-dl-link" href="{{ route('videos.downloadMp3', $video) }}"
|
||||
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;font-size:14px;padding:8px 14px;text-decoration:none;">
|
||||
<i class="bi bi-music-note-beamed"></i> Download MP3
|
||||
</a>
|
||||
@@ -292,7 +292,7 @@
|
||||
<i class="bi bi-film"></i> Download Video
|
||||
</a>
|
||||
@endif
|
||||
<a class="dropdown-item" href="{{ route('videos.downloadMp3', $video) }}"
|
||||
<a class="dropdown-item ytp-mp3-dl-link" href="{{ route('videos.downloadMp3', $video) }}"
|
||||
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;">
|
||||
<i class="bi bi-music-note-beamed"></i> Download MP3
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@props(['video' => null, 'size' => 'medium'])
|
||||
|
||||
@php
|
||||
use App\Data\Languages;
|
||||
|
||||
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
|
||||
$thumbnailUrl = $video && $video->thumbnail
|
||||
? route('media.thumbnail', $video->thumbnail)
|
||||
@@ -18,6 +20,9 @@ $isShorts = $video && $video->isShorts();
|
||||
// Check if current user is the owner of the video
|
||||
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
|
||||
|
||||
// Language flag code (null when no language set)
|
||||
$langFlag = $video ? Languages::flag($video->language) : null;
|
||||
|
||||
// Size classes
|
||||
$sizeClasses = match($size) {
|
||||
'small' => 'yt-video-card-sm',
|
||||
@@ -70,6 +75,9 @@ $sizeClasses = match($size) {
|
||||
<div class="yt-video-details">
|
||||
<h3 class="yt-video-title">
|
||||
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
|
||||
@if($langFlag)
|
||||
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
|
||||
@endif
|
||||
{{ $video->title ?? 'Untitled Video' }}
|
||||
</a>
|
||||
</h3>
|
||||
@@ -481,6 +489,19 @@ $sizeClasses = match($size) {
|
||||
.yt-video-card .yt-video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
.vc-lang-flag {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-channel-icon {
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
@if($isVideoOwner)
|
||||
<style>
|
||||
/* ── Stat cards ──────────────────────────────────────── */
|
||||
.ins-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-bottom:18px; }
|
||||
@media(max-width:680px){ .ins-grid { grid-template-columns:repeat(2,1fr); } }
|
||||
.ins-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:18px; }
|
||||
@media(max-width:860px){ .ins-grid { grid-template-columns:repeat(3,1fr); } }
|
||||
@media(max-width:560px){ .ins-grid { grid-template-columns:repeat(2,1fr); } }
|
||||
.ins-card { background:var(--bg-dark); border:1px solid var(--border-color); border-radius:10px; padding:14px 14px 12px; display:flex; flex-direction:column; gap:4px; cursor:pointer; transition:border-color .15s, transform .12s, box-shadow .15s; user-select:none; }
|
||||
.ins-card:hover { border-color:rgba(239,68,68,.5); transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,.3); }
|
||||
.ins-card:active { transform:translateY(0); }
|
||||
@@ -157,7 +158,8 @@
|
||||
</style>
|
||||
|
||||
{{-- Panel: placed inside .vdb-wrap right after the About panel --}}
|
||||
<div class="vdb-panel" id="vdb-insights">
|
||||
<div class="vdb-panel" id="vdb-insights"
|
||||
data-insights-base="{{ url('/videos/' . $video->getRouteKey() . '/insights') }}">
|
||||
<div id="insightsContent">
|
||||
<div class="ins-skeleton">
|
||||
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
|
||||
@@ -185,11 +187,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window._insLoaded = false;
|
||||
window._insData = null;
|
||||
window._insLoaded = false;
|
||||
window._insLoadedUrl = null;
|
||||
window._insData = null;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
const _cflag = code => (!code||code.length!==2) ? '🌐' : code.toUpperCase().split('').map(c=>String.fromCodePoint(0x1F1E6+c.charCodeAt(0)-65)).join('');
|
||||
const _cflag = code => `<span class="fi fi-${(!code||code.length!==2)?'xx':code.toLowerCase()}" style="width:20px;height:15px;border-radius:2px;display:inline-block;flex-shrink:0;vertical-align:middle;"></span>`;
|
||||
const _fmt = n => { n=n??0; if(n>=1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M'; if(n>=1e3) return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K'; return String(n); };
|
||||
const _fmtH = h => (h===null||h===undefined) ? '—' : (h%12||12)+':00 '+(h>=12?'PM':'AM');
|
||||
const _ago = s => { const d=Math.floor((Date.now()-new Date(s))/1000); if(d<60) return d+'s ago'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; };
|
||||
@@ -197,7 +200,7 @@ const _fmtDt = s => new Date(s).toLocaleDateString('en-US',{month:'short',day:'n
|
||||
|
||||
// ── Modal helpers ──────────────────────────────────────
|
||||
function _openModal(icon, title, subtitle, html) {
|
||||
document.getElementById('insModalIcon').textContent = icon;
|
||||
document.getElementById('insModalIcon').innerHTML = icon;
|
||||
document.getElementById('insModalTitle').textContent = title;
|
||||
document.getElementById('insModalSubtitle').textContent = subtitle || '';
|
||||
document.getElementById('insModalBody').innerHTML = html;
|
||||
@@ -265,9 +268,23 @@ function _hourlyChart(hourly) {
|
||||
// LOAD MAIN INSIGHTS
|
||||
// ══════════════════════════════════════════════════════
|
||||
function loadInsights() {
|
||||
fetch('{{ route('videos.insights', $video) }}', { headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'} })
|
||||
const panel = document.getElementById('vdb-insights');
|
||||
const baseUrl = panel && panel.dataset.insightsBase;
|
||||
if (!baseUrl) return;
|
||||
|
||||
// Reset to skeleton so stale data from the previous video never shows
|
||||
document.getElementById('insightsContent').innerHTML =
|
||||
`<div class="ins-skeleton">
|
||||
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
|
||||
<div class="ins-skel-row" style="width:100%;height:80px;"></div>
|
||||
<div class="ins-skel-row" style="width:80%;"></div>
|
||||
<div class="ins-skel-row" style="width:90%;"></div>
|
||||
<div class="ins-skel-row" style="width:70%;"></div>
|
||||
</div>`;
|
||||
|
||||
fetch(baseUrl, { headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'} })
|
||||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||||
.then(d => { window._insLoaded=true; window._insData=d; renderInsights(d); })
|
||||
.then(d => { window._insLoaded=true; window._insLoadedUrl=baseUrl; window._insData=d; renderInsights(d); })
|
||||
.catch(() => {
|
||||
document.getElementById('insightsContent').innerHTML =
|
||||
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load insights. Try again later.</p>';
|
||||
@@ -304,6 +321,11 @@ function renderInsights(d) {
|
||||
<div class="ins-card-icon">⬇️</div><div class="ins-card-val">${_fmt(d.downloads)}</div>
|
||||
<div class="ins-card-label">Downloads</div>${dlSub}
|
||||
</div>
|
||||
<div class="ins-card" onclick="openInsModal_likes()">
|
||||
<div class="ins-card-icon">❤️</div><div class="ins-card-val">${_fmt(d.likes)}</div>
|
||||
<div class="ins-card-label">Likes</div>
|
||||
<span class="ins-card-sub neu">${d.likes===0?'no likes yet':_fmt(d.likes)+' '+(d.likes===1?'person':'people')}</span>
|
||||
</div>
|
||||
<div class="ins-card" onclick="openInsModal_shares()">
|
||||
<div class="ins-card-icon">🔗</div><div class="ins-card-val">${_fmt(d.shares)}</div>
|
||||
<div class="ins-card-label">Share Reach</div>
|
||||
@@ -527,15 +549,27 @@ function renderInsights(d) {
|
||||
// ── Share links breakdown ───────────────────────────
|
||||
let shareHtml = '';
|
||||
if (d.share_links > 0) {
|
||||
const linkRows = (d.share_breakdown || []).map((s, i) => `
|
||||
<div class="ins-dl-user-row" style="cursor:default;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="ins-dl-user-name">${s.sharer}</div>
|
||||
<div class="ins-dl-user-meta">${_ago(s.created_at)}</div>
|
||||
</div>
|
||||
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
|
||||
</div>`).join('');
|
||||
const linkRows = (d.share_breakdown || []).map((s, i) => {
|
||||
const avatarHtml = s.avatar
|
||||
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
|
||||
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
|
||||
const clickAttr = s.sharer_channel
|
||||
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"`
|
||||
: `style="cursor:default;"`;
|
||||
const flagHtml = s.country
|
||||
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
|
||||
: '';
|
||||
return `<div class="ins-dl-user-row" ${clickAttr}>
|
||||
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||||
${avatarHtml}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="ins-dl-user-name">${s.sharer}</div>
|
||||
<div class="ins-dl-user-meta">${_fmtDt(s.created_at)} · ${_ago(s.created_at)}</div>
|
||||
</div>
|
||||
${flagHtml}
|
||||
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
shareHtml = `
|
||||
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
|
||||
@@ -651,15 +685,27 @@ function openInsModal_shares() {
|
||||
const engRate = d.total_views>0 ? (((d.shares)+d.likes)/d.total_views*100).toFixed(1) : '0.0';
|
||||
|
||||
const linkRows = (d.share_breakdown||[]).length
|
||||
? (d.share_breakdown||[]).map((s,i) => `
|
||||
<div class="ins-dl-user-row" style="cursor:default;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="ins-dl-user-name">${s.sharer}</div>
|
||||
<div class="ins-dl-user-meta">Created: ${_ago(s.created_at)}</div>
|
||||
</div>
|
||||
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
|
||||
</div>`).join('')
|
||||
? (d.share_breakdown||[]).map((s,i) => {
|
||||
const avatarHtml = s.avatar
|
||||
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
|
||||
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
|
||||
const clickAttr = s.sharer_channel
|
||||
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"`
|
||||
: `style="cursor:default;"`;
|
||||
const flagHtml = s.country
|
||||
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
|
||||
: '';
|
||||
return `<div class="ins-dl-user-row" ${clickAttr}>
|
||||
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||||
${avatarHtml}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="ins-dl-user-name">${s.sharer}</div>
|
||||
<div class="ins-dl-user-meta">${_fmtDt(s.created_at)} · ${_ago(s.created_at)}</div>
|
||||
</div>
|
||||
${flagHtml}
|
||||
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
|
||||
</div>`;
|
||||
}).join('')
|
||||
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>';
|
||||
|
||||
_openModal('🔗','Share Reach','Unique devices that opened your share links',`
|
||||
@@ -675,14 +721,44 @@ function openInsModal_shares() {
|
||||
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Each person who shares gets a unique link. Same device opening the same link counts only once.</p>`);
|
||||
}
|
||||
|
||||
function openInsModal_likes() {
|
||||
const d = window._insData; if(!d) return;
|
||||
if (d.likes === 0) {
|
||||
_openModal('❤️','Likes','No likes yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">🤍</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No one has liked this video yet.</p></div>`);
|
||||
return;
|
||||
}
|
||||
const likeRate = d.total_views>0 ? ((d.likes/d.total_views)*100).toFixed(1) : '0.0';
|
||||
const likerRows = (d.likers||[]).map((u,i) => `
|
||||
<div class="ins-person-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||||
<img src="${u.avatar}" class="ins-person-avatar" alt="${u.name}">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="ins-person-name">${u.name}</div>
|
||||
<div class="ins-person-meta">${_ago(u.liked_at)}</div>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;"></i>
|
||||
</div>`).join('');
|
||||
|
||||
_openModal('❤️','Likes',`${_fmt(d.likes)} ${d.likes===1?'person':'people'} liked this`,`
|
||||
<div class="ins-modal-hero">
|
||||
<div class="ins-modal-hero-num">${_fmt(d.likes)}</div>
|
||||
<div class="ins-modal-hero-label">${d.likes===1?'person':'people'} liked this video</div>
|
||||
</div>
|
||||
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Like rate</span><span class="ins-modal-stat-val">${likeRate}% of viewers</span></div>
|
||||
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
|
||||
<div class="ins-modal-section"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Who liked</div>
|
||||
${likerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data available.</p>'}`);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// DRILL-DOWN: COUNTRY
|
||||
// ══════════════════════════════════════════════════════
|
||||
function openCountryDetail(code, name, totalCount) {
|
||||
const flag = _cflag(code);
|
||||
const flag = _cflag(code);
|
||||
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
|
||||
_modalLoading(flag, name||code, `${_fmt(totalCount)} views from this country`);
|
||||
|
||||
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/country') }}/${code}`, {
|
||||
fetch(`${baseUrl}/country/${code}`, {
|
||||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||||
})
|
||||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||||
@@ -727,9 +803,10 @@ function openCountryDetail(code, name, totalCount) {
|
||||
// DRILL-DOWN: SPECIFIC DAY
|
||||
// ══════════════════════════════════════════════════════
|
||||
function openDayDetail(date, label, totalCount) {
|
||||
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
|
||||
_modalLoading('📅', label, `${_fmt(totalCount)} views on this day`);
|
||||
|
||||
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/day') }}/${date}`, {
|
||||
fetch(`${baseUrl}/day/${date}`, {
|
||||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||||
})
|
||||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||||
@@ -745,7 +822,7 @@ function openDayDetail(date, label, totalCount) {
|
||||
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
|
||||
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
|
||||
|
||||
document.getElementById('insModalIcon').textContent = '📅';
|
||||
document.getElementById('insModalIcon').innerHTML = '📅';
|
||||
document.getElementById('insModalTitle').textContent = `${d.day_of_week}, ${d.date}`;
|
||||
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views on this day`;
|
||||
document.getElementById('insModalBody').innerHTML = `
|
||||
@@ -779,10 +856,11 @@ function openDayDetail(date, label, totalCount) {
|
||||
// DRILL-DOWN: DOWNLOADER HISTORY
|
||||
// ══════════════════════════════════════════════════════
|
||||
function openDownloaderHistory(userId, userName, userAvatar) {
|
||||
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
|
||||
_modalLoading('⬇️', userName, 'Loading download history…');
|
||||
document.getElementById('insModalIcon').innerHTML = `<img src="${userAvatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
|
||||
|
||||
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/downloader') }}/${userId}`, {
|
||||
fetch(`${baseUrl}/downloader/${userId}`, {
|
||||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||||
})
|
||||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
default => '',
|
||||
};
|
||||
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
|
||||
$mp4Url = route('videos.stream', $video);
|
||||
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
|
||||
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
|
||||
$prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
||||
|
||||
@@ -154,13 +154,6 @@
|
||||
</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" id="ytpLoopSwitch"><div class="ytp-toggle-thumb"></div></div>
|
||||
</div>
|
||||
{{-- Autoplay toggle (playlist only) --}}
|
||||
@if($playlist)
|
||||
<div class="ytp-settings-item ytp-toggle-row" id="ytpAutoplayRow">
|
||||
@@ -182,6 +175,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loop — outside gear, immediately to its right --}}
|
||||
<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>
|
||||
|
||||
{{-- Picture-in-Picture --}}
|
||||
<button class="ytp-button ytp-pip-btn" id="ytpPipBtn" title="Miniplayer (i)">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"/></svg>
|
||||
@@ -222,9 +220,9 @@
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 70vh;
|
||||
}
|
||||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-width: 50vh; margin: 0 auto; max-height: unset; }
|
||||
.ytp-wrap.square { aspect-ratio: 1/1; max-width: 70vh; margin: 0 auto; max-height: unset; }
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: unset; }
|
||||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
|
||||
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
|
||||
|
||||
/* Theater mode */
|
||||
.ytp-wrap.theater {
|
||||
@@ -586,7 +584,9 @@
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; }
|
||||
.ytp-wrap.portrait { max-height: unset; }
|
||||
.ytp-wrap.portrait { max-height: 75vh; width: auto; max-width: 100%; }
|
||||
.ytp-wrap.square { max-height: 85vw; max-width: 85vw; }
|
||||
.ytp-wrap.ultrawide { max-height: 50vw; }
|
||||
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
|
||||
@@ -600,6 +600,11 @@
|
||||
/* Hide PIP if not supported – handled by JS */
|
||||
.ytp-pip-btn.unsupported { display: none; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* Settings panel toggle rows */
|
||||
.ytp-toggle-row { border-top: 1px solid rgba(255,255,255,.08); }
|
||||
.ytp-toggle-row svg { opacity: .75; }
|
||||
@@ -705,8 +710,7 @@ const speedOpts = document.querySelectorAll('.ytp-speed-option');
|
||||
const fsBtn = document.getElementById('ytpFsBtn');
|
||||
const pipBtn = document.getElementById('ytpPipBtn');
|
||||
const theaterBtn = document.getElementById('ytpTheaterBtn');
|
||||
const loopRow = document.getElementById('ytpLoopRow');
|
||||
const loopVal = document.getElementById('ytpLoopVal');
|
||||
const loopBtn = document.getElementById('ytpLoopBtn');
|
||||
|
||||
// ── State ─────────────────────────────────────────────
|
||||
let hideTimer = null;
|
||||
@@ -806,7 +810,12 @@ function getShuffledPrevUrl() {
|
||||
return vids[order[posPrev]]?.showUrl ?? PREV_URL;
|
||||
}
|
||||
|
||||
window._ytpHls = null;
|
||||
window._ytpHls = null;
|
||||
window._ytpMasterHls = @json($hlsUrl);
|
||||
window._ytpMasterMp4 = @json($mp4Url);
|
||||
window._ytpWasPlaying = false;
|
||||
video.addEventListener('play', function () { window._ytpWasPlaying = true; });
|
||||
video.addEventListener('pause', function () { window._ytpWasPlaying = false; });
|
||||
|
||||
function initSource() {
|
||||
video.muted = true;
|
||||
@@ -841,13 +850,18 @@ function initSource() {
|
||||
|
||||
// Reinitialize source after SPA transition — called by playlist overlay scripts
|
||||
window._ytpLoadSource = function(hlsUrl, mp4Url) {
|
||||
window._ytpMasterHls = hlsUrl || null;
|
||||
window._ytpMasterMp4 = mp4Url || null;
|
||||
const _vol = video.volume;
|
||||
const _muted = video.muted;
|
||||
if (window._ytpHls) { window._ytpHls.destroy(); window._ytpHls = null; }
|
||||
video.muted = true;
|
||||
video.autoplay = true;
|
||||
if (hlsUrl && window.Hls && Hls.isSupported()) {
|
||||
window._ytpHls = new Hls({ startLevel: -1 });
|
||||
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, function() {
|
||||
video.muted = false;
|
||||
video.volume = _vol;
|
||||
video.muted = _muted;
|
||||
video.play().catch(function(){});
|
||||
});
|
||||
window._ytpHls.loadSource(hlsUrl);
|
||||
@@ -855,15 +869,17 @@ window._ytpLoadSource = function(hlsUrl, mp4Url) {
|
||||
} else if (hlsUrl && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = hlsUrl;
|
||||
video.load();
|
||||
video.volume = _vol;
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.muted = false;
|
||||
video.muted = _muted;
|
||||
video.play().catch(function(){});
|
||||
}, { once: true });
|
||||
} else {
|
||||
video.src = mp4Url;
|
||||
video.load();
|
||||
video.volume = _vol;
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.muted = false;
|
||||
video.muted = _muted;
|
||||
video.play().catch(function(){});
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -1156,18 +1172,19 @@ if (theaterBtn) {
|
||||
let isLooping = localStorage.getItem('ytpLoop') === '1';
|
||||
video.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;
|
||||
video.loop = isLooping;
|
||||
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
|
||||
applyLoopState();
|
||||
showControls();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1354,6 +1371,30 @@ function init() {
|
||||
// Initial state
|
||||
largePlay.classList.add('visible');
|
||||
showControls();
|
||||
|
||||
// Scroll-based mini player: watch when #ytpWrap leaves the viewport
|
||||
if (window.IntersectionObserver && window._miniPlayer) {
|
||||
/* On mobile, #main is the scroll container; on desktop the window scrolls */
|
||||
var _scrollRoot = window.innerWidth <= 768 ? document.getElementById('main') : null;
|
||||
var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */
|
||||
new IntersectionObserver(function (entries) {
|
||||
var e0 = entries[0];
|
||||
/* Only activate after the video has actually started playing (_ytpWasPlaying).
|
||||
Using !video.paused was unreliable: autoplay fires asynchronously and the
|
||||
initial IntersectionObserver callback could run before HLS.js even attaches,
|
||||
teleporting the element before it ever played in the main player. */
|
||||
if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && !window._miniPlayer.isNavMode()) {
|
||||
_scrollMiniOn = true;
|
||||
window._miniPlayer.activateScroll(
|
||||
document.title.replace(/\s*\|.*$/, '').trim(),
|
||||
window.location.href
|
||||
);
|
||||
} else if (e0.isIntersecting && _scrollMiniOn) {
|
||||
_scrollMiniOn = false;
|
||||
window._miniPlayer.deactivateScroll();
|
||||
}
|
||||
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
Reference in new issue
Block a user