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
@@ -481,7 +481,7 @@
|
||||
@foreach($viewsByCountry as $i => $row)
|
||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||
<div class="country-rank">{{ $i + 1 }}</div>
|
||||
<div class="country-flag" title="{{ $row->country }}">{{ $row->country ? countryCodeToFlag($row->country) : '🌍' }}</div>
|
||||
<div class="country-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
||||
@@ -677,13 +677,9 @@
|
||||
|
||||
@php
|
||||
function countryCodeToFlag(string $code): string {
|
||||
$code = strtoupper(trim($code));
|
||||
if (strlen($code) !== 2) return '🌍';
|
||||
$chars = '';
|
||||
foreach (str_split($code) as $c) {
|
||||
$chars .= mb_chr(0x1F1E6 + ord($c) - ord('A'));
|
||||
}
|
||||
return $chars;
|
||||
$iso = strtolower(trim($code));
|
||||
$safe = (strlen($iso) === 2 && ctype_alpha($iso)) ? $iso : 'xx';
|
||||
return '<span class="fi fi-' . $safe . '" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>';
|
||||
}
|
||||
@endphp
|
||||
|
||||
@@ -755,22 +751,20 @@ new Chart(document.getElementById('typeChart'), {
|
||||
const canvas = document.getElementById('countryChart');
|
||||
const container = canvas.parentElement; // position:relative — flags overlay here
|
||||
|
||||
// Overlay real <img> flags — canvas ctx cannot render emoji flag sequences reliably.
|
||||
// yAxis.right = left edge of bar area; flags sit in the ticks.padding gap before the bars.
|
||||
// Overlay flag spans using local flag-icons SVGs — works on all browsers, no CDN.
|
||||
function placeFlagOverlays() {
|
||||
container.querySelectorAll('.cty-flag').forEach(e => e.remove());
|
||||
const yAxis = chart.scales.y;
|
||||
if (!yAxis) return;
|
||||
const fW = 22, fH = 15;
|
||||
// Place flag just to the left of the bar area (inside the ticks.padding gap)
|
||||
const x = Math.round(yAxis.right - fW - 4);
|
||||
countryData.forEach((row, i) => {
|
||||
if (!row.country || row.country.length !== 2) return;
|
||||
const yPx = Math.round(yAxis.getPixelForTick(i));
|
||||
const img = document.createElement('img');
|
||||
img.src = `https://flagcdn.com/w40/${row.country.toLowerCase()}.png`;
|
||||
img.className = 'cty-flag';
|
||||
img.style.cssText = [
|
||||
const code = row.country.toLowerCase();
|
||||
const el = document.createElement('span');
|
||||
el.className = `cty-flag fi fi-${code}`;
|
||||
el.style.cssText = [
|
||||
'position:absolute',
|
||||
`width:${fW}px`,
|
||||
`height:${fH}px`,
|
||||
@@ -778,10 +772,10 @@ new Chart(document.getElementById('typeChart'), {
|
||||
`left:${x}px`,
|
||||
'pointer-events:none',
|
||||
'border-radius:3px',
|
||||
'object-fit:cover',
|
||||
'box-shadow:0 1px 3px rgba(0,0,0,.4)',
|
||||
'background-size:cover',
|
||||
].join(';');
|
||||
container.appendChild(img);
|
||||
container.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
|
||||
@php
|
||||
function flagEmoji(string $code): string {
|
||||
$chars = '';
|
||||
foreach (str_split(strtoupper($code)) as $c) {
|
||||
$chars .= mb_chr(0x1F1E6 + ord($c) - ord('A'));
|
||||
}
|
||||
return $chars;
|
||||
$iso = strtolower(trim($code));
|
||||
$safe = (strlen($iso) === 2 && ctype_alpha($iso)) ? $iso : 'xx';
|
||||
return '<span class="fi fi-' . $safe . '" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>';
|
||||
}
|
||||
@endphp
|
||||
|
||||
@@ -295,7 +293,7 @@ function flagEmoji(string $code): string {
|
||||
@forelse($viewsByCountry as $i => $row)
|
||||
<div class="country-row">
|
||||
<span class="country-rank">{{ $i + 1 }}</span>
|
||||
<span class="country-flag">{{ flagEmoji($row->country) }}</span>
|
||||
<span class="country-flag">{!! flagEmoji($row->country) !!}</span>
|
||||
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
||||
@@ -424,7 +422,7 @@ function flagEmoji(string $code): string {
|
||||
</td>
|
||||
<td>
|
||||
@if($view->country)
|
||||
<span style="font-size:16px;">{{ flagEmoji($view->country) }}</span>
|
||||
{!! flagEmoji($view->country) !!}
|
||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
|
||||
@else
|
||||
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
|
||||
<style>
|
||||
:root {
|
||||
--brand-red: #e61e1e;
|
||||
@@ -822,6 +823,201 @@
|
||||
.pl-ctrl-divider { width: 1px; height: 18px; background: var(--border-color); margin: 0 2px; flex-shrink: 0; }
|
||||
.pl-ctrl-autoplay { margin-left: auto; }
|
||||
.pl-autoplay-label { font-size: 12px; font-weight: 500; }
|
||||
|
||||
/* ── Mini-player ─────────────────────────────────────── */
|
||||
#ytpMini {
|
||||
position: fixed;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
right: 12px;
|
||||
width: 300px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
z-index: 1999;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.6);
|
||||
}
|
||||
#ytpMiniVideo {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
#ytpMiniVideo video { width:100%; height:100%; object-fit:contain; display:block; }
|
||||
#ytpMiniVideo .ytp-chrome-bottom,
|
||||
#ytpMiniVideo .ytp-gradient-bottom,
|
||||
#ytpMiniVideo .ytp-large-play-btn,
|
||||
#ytpMiniVideo .ytp-spinner,
|
||||
#ytpMiniVideo .ytp-dbl-left,
|
||||
#ytpMiniVideo .ytp-dbl-right { display:none !important; }
|
||||
#ytpMiniBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
gap: 6px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
#ytpMiniInfo { flex:1; min-width:0; }
|
||||
#ytpMiniTitle {
|
||||
font-size: 12px;
|
||||
color: #eee;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
#ytpMiniControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#ytpMiniControls button,
|
||||
#ytpMiniControls a {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
#ytpMiniControls button:hover,
|
||||
#ytpMiniControls a:hover { color:#fff; background:rgba(255,255,255,.1); }
|
||||
@media (max-width: 480px) {
|
||||
#ytpMini { width: calc(100vw - 24px); right: 12px; }
|
||||
}
|
||||
|
||||
/* ── Mobile responsive ───────────────────────────────── */
|
||||
@media (max-width: 480px) {
|
||||
.yt-menu-btn, .yt-icon-btn, .yt-mobile-search-toggle {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.yt-main { padding: 12px 8px !important; }
|
||||
.yt-video-title, .video-title { font-size: 14px !important; }
|
||||
.channel-info { gap: 8px !important; }
|
||||
.channel-avatar { width: 32px !important; height: 32px !important; }
|
||||
.channel-name { font-size: 14px !important; }
|
||||
.channel-subs { font-size: 12px !important; }
|
||||
.yt-channel-name, .yt-video-meta { font-size: 12px !important; }
|
||||
.video-actions {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.yt-action-btn { flex-shrink: 0; padding: 8px 12px; font-size: 13px; }
|
||||
.comment-item { flex-direction: column; }
|
||||
.comment-item > img { width: 32px !important; height: 32px !important; }
|
||||
.yt-search-input { font-size: 14px; padding: 0 12px; }
|
||||
.yt-header { padding: 0 8px; }
|
||||
.dropdown-menu { width: 100%; min-width: 200px; }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.yt-main { padding: 8px 4px !important; }
|
||||
.yt-main.video-view-page { padding: 0 !important; }
|
||||
.yt-header-right .yt-icon-btn:not(:first-child) { display: none; }
|
||||
}
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.yt-sidebar { width: 200px; }
|
||||
.yt-video-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
|
||||
}
|
||||
@media (min-width: 1440px) {
|
||||
.yt-video-grid { grid-template-columns: repeat(4, 1fr) !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.video-container {
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.yt-sidebar-link { padding: 0 16px; }
|
||||
.yt-sidebar-link:hover { background: transparent; }
|
||||
.yt-sidebar-link:active { background: var(--border-color); }
|
||||
}
|
||||
|
||||
/* ── Bottom nav + mobile scroll model ───────────────── */
|
||||
.yt-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
background: var(--bg-dark);
|
||||
border-top: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
will-change: transform;
|
||||
}
|
||||
.yt-bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
transition: color 0.2s;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
min-width: 56px;
|
||||
}
|
||||
.yt-bottom-nav-item:hover { color: var(--text-primary); }
|
||||
.yt-bottom-nav-item.active { color: var(--text-primary); }
|
||||
.yt-bottom-nav-item i { font-size: 24px; }
|
||||
.yt-bottom-nav-item span { font-size: 10px; font-weight: 500; }
|
||||
@media (max-width: 768px) {
|
||||
html { overflow: hidden; height: 100%; max-width: 100%; }
|
||||
body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
.yt-bottom-nav { display: flex; transform: none !important; }
|
||||
.yt-main {
|
||||
position: fixed !important;
|
||||
top: 56px !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
margin: 0 !important;
|
||||
padding: 16px !important;
|
||||
padding-bottom: 16px !important;
|
||||
min-height: unset !important;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
|
||||
.yt-main.video-view-page { padding: 0 !important; }
|
||||
.yt-filter-bar {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
margin: -16px -16px 16px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@yield('extra_styles')
|
||||
@@ -1113,6 +1309,7 @@
|
||||
@auth
|
||||
(function () {
|
||||
var fetchUrl = '{{ route("notifications.fetch") }}';
|
||||
var countUrl = '{{ route("notifications.count") }}';
|
||||
var readAllUrl = '{{ route("notifications.read-all") }}';
|
||||
var csrf = '{{ csrf_token() }}';
|
||||
var loaded = false;
|
||||
@@ -1356,9 +1553,9 @@
|
||||
post(readAllUrl);
|
||||
};
|
||||
|
||||
// ── Poll every 30 s for new notifications ──────────────
|
||||
// ── Poll every 60 s — count-only (1 lightweight DB query) ──
|
||||
function poll() {
|
||||
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||
fetch(countUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var newCount = data.unread_count || 0;
|
||||
@@ -1368,32 +1565,34 @@
|
||||
badge.textContent = newCount > 99 ? '99+' : newCount;
|
||||
badge.style.display = '';
|
||||
if (markAllBtn) markAllBtn.style.display = '';
|
||||
renderNotifications(data);
|
||||
loaded = true;
|
||||
setBellUnread(true);
|
||||
loaded = false; // force re-fetch when panel opens
|
||||
} else if (newCount === 0 && prevUnread > 0) {
|
||||
badge.style.display = 'none';
|
||||
if (markAllBtn) markAllBtn.style.display = 'none';
|
||||
setBellUnread(false);
|
||||
}
|
||||
prevUnread = newCount;
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
setInterval(poll, 30000);
|
||||
setInterval(poll, 60000);
|
||||
|
||||
// ── Initial badge load ─────────────────────────────────
|
||||
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
prevUnread = data.unread_count || 0;
|
||||
if (prevUnread > 0) {
|
||||
badge.textContent = prevUnread > 99 ? '99+' : prevUnread;
|
||||
badge.style.display = '';
|
||||
if (markAllBtn) markAllBtn.style.display = '';
|
||||
renderNotifications(data);
|
||||
loaded = true;
|
||||
setBellUnread(true);
|
||||
ringBell();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
// ── Initial badge load — deferred so it doesn't block page render ──
|
||||
(window.requestIdleCallback || function(cb){ setTimeout(cb, 500); })(function () {
|
||||
fetch(countUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
prevUnread = data.unread_count || 0;
|
||||
if (prevUnread > 0) {
|
||||
badge.textContent = prevUnread > 99 ? '99+' : prevUnread;
|
||||
badge.style.display = '';
|
||||
if (markAllBtn) markAllBtn.style.display = '';
|
||||
setBellUnread(true);
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
})();
|
||||
@endauth
|
||||
|
||||
@@ -1651,462 +1850,265 @@
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
<!-- ── Persistent Mini-Player ─────────────────────────────────────────
|
||||
Shown on non-video pages when the user navigated away while a
|
||||
video was playing. State is stored in sessionStorage under the
|
||||
key "ytpMiniState".
|
||||
<!-- ── Persistent Mini-Player ──────────────────────────────────────────
|
||||
#videoPlayer is teleported into #ytpMiniVideo via DOM adoption so
|
||||
HLS.js keeps streaming without any reconnection or discontinuity.
|
||||
Activated by scroll (video leaves viewport) or by SPA navigation.
|
||||
──────────────────────────────────────────────────────────────────── -->
|
||||
<div id="ytpMini" style="display:none;" aria-label="Mini player">
|
||||
<div id="ytpMiniVideo">
|
||||
<video id="ytpMiniVid" playsinline></video>
|
||||
</div>
|
||||
<div id="ytpMiniVideo"></div>
|
||||
<div id="ytpMiniBar">
|
||||
<div id="ytpMiniInfo">
|
||||
<span id="ytpMiniTitle"></span>
|
||||
</div>
|
||||
<div id="ytpMiniControls">
|
||||
<button id="ytpMiniPlay" title="Play / Pause"><i class="bi bi-play-fill"></i></button>
|
||||
<a id="ytpMiniExpand" title="Open video"><i class="bi bi-box-arrow-up-right"></i></a>
|
||||
<a id="ytpMiniExpand" title="Back to video" href="#"><i class="bi bi-box-arrow-up-right"></i></a>
|
||||
<button id="ytpMiniClose" title="Close"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#ytpMini {
|
||||
position: fixed;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
right: 12px;
|
||||
width: 280px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
z-index: 1999;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.6);
|
||||
}
|
||||
#ytpMiniVideo {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
}
|
||||
#ytpMiniVid { width:100%; height:100%; object-fit:contain; display:block; }
|
||||
#ytpMiniBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
gap: 6px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
#ytpMiniInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
#ytpMiniTitle {
|
||||
font-size: 12px;
|
||||
color: #eee;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
#ytpMiniControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#ytpMiniControls button,
|
||||
#ytpMiniControls a {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
#ytpMiniControls button:hover,
|
||||
#ytpMiniControls a:hover { color: #fff; background: rgba(255,255,255,.1); }
|
||||
@media (max-width: 480px) {
|
||||
#ytpMini { width: calc(100vw - 24px); right: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var STORAGE_KEY = 'ytpMiniState';
|
||||
/* ── Mini-player controller (teleportation-based) ─────────────────────
|
||||
Moves the actual #videoPlayer element into the mini slot so HLS.js
|
||||
never drops the stream. Two modes:
|
||||
'scroll' — player scrolled out of viewport on the video page
|
||||
'nav' — user navigated to a non-video page via SPA
|
||||
───────────────────────────────────────────────────────────────────── */
|
||||
window._miniPlayer = (function () {
|
||||
var wrap = document.getElementById('ytpMini');
|
||||
var slot = document.getElementById('ytpMiniVideo');
|
||||
var titleEl = document.getElementById('ytpMiniTitle');
|
||||
var playBtn = document.getElementById('ytpMiniPlay');
|
||||
var expandBtn = document.getElementById('ytpMiniExpand');
|
||||
var closeBtn = document.getElementById('ytpMiniClose');
|
||||
|
||||
/* ── Save state when nav bar is tapped while video plays ── */
|
||||
document.querySelectorAll('.yt-bottom-nav-item').forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
var v = document.getElementById('videoPlayer');
|
||||
if (!v || v.paused || !v.currentSrc) return;
|
||||
var _mode = null; /* 'scroll' | 'nav' | null */
|
||||
var _origParent = null;
|
||||
var _origNext = null;
|
||||
|
||||
var title = document.title.replace(/\s*\|.*$/, '').trim();
|
||||
var videoPageUrl = window.location.href;
|
||||
function getVid() { return document.getElementById('videoPlayer'); }
|
||||
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
src: v.currentSrc,
|
||||
time: v.currentTime,
|
||||
title: title,
|
||||
url: videoPageUrl,
|
||||
muted: v.muted,
|
||||
volume: v.volume
|
||||
}));
|
||||
function syncBtn() {
|
||||
var v = getVid();
|
||||
if (!playBtn) return;
|
||||
playBtn.querySelector('i').className = (!v || v.paused)
|
||||
? 'bi bi-play-fill' : 'bi bi-pause-fill';
|
||||
}
|
||||
|
||||
function activate(title, url, mode) {
|
||||
if (_mode !== null) return false; /* already active — prevent re-entry */
|
||||
var v = getVid();
|
||||
if (!v || !slot) return false;
|
||||
|
||||
/* Remember original DOM position to restore later */
|
||||
_origParent = v.parentNode;
|
||||
_origNext = v.nextSibling;
|
||||
|
||||
/* Teleport — HLS.js stays attached, playback uninterrupted */
|
||||
slot.appendChild(v);
|
||||
|
||||
titleEl.textContent = title || 'Video';
|
||||
expandBtn.href = url || '#';
|
||||
_mode = mode;
|
||||
wrap.style.display = 'block';
|
||||
syncBtn();
|
||||
v.addEventListener('play', syncBtn);
|
||||
v.addEventListener('pause', syncBtn);
|
||||
return true;
|
||||
}
|
||||
|
||||
function restore() {
|
||||
var v = getVid();
|
||||
if (!v || !_origParent) return;
|
||||
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
|
||||
_origParent.insertBefore(v, _origNext);
|
||||
} else {
|
||||
_origParent.appendChild(v);
|
||||
}
|
||||
_origParent = null;
|
||||
_origNext = null;
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
restore();
|
||||
wrap.style.display = 'none';
|
||||
_mode = null;
|
||||
}
|
||||
|
||||
if (playBtn) {
|
||||
playBtn.addEventListener('click', function () {
|
||||
var v = getVid();
|
||||
if (!v) return;
|
||||
if (v.paused) v.play().catch(function(){});
|
||||
else v.pause();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── On page load, restore mini-player if state exists ── */
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
/* If we're on the video page itself, clear the saved state */
|
||||
var mainVideo = document.getElementById('videoPlayer');
|
||||
if (mainVideo) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
if (expandBtn) {
|
||||
expandBtn.addEventListener('click', function (e) {
|
||||
if (_mode === 'scroll') {
|
||||
e.preventDefault();
|
||||
/* Scroll back to the player and restore it */
|
||||
var main = document.getElementById('main');
|
||||
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
deactivate();
|
||||
}
|
||||
/* nav mode: follow href — full page navigation to video */
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function () {
|
||||
var v = getVid();
|
||||
if (v) v.pause();
|
||||
if (_mode === 'scroll') restore();
|
||||
wrap.style.display = 'none';
|
||||
_mode = null;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
activate: function (t, u) { return activate(t, u, 'nav'); },
|
||||
activateScroll: function (t, u) { return activate(t, u, 'scroll'); },
|
||||
deactivate: deactivate,
|
||||
deactivateScroll: function () { if (_mode === 'scroll') deactivate(); },
|
||||
isActive: function () { return _mode !== null; },
|
||||
isScrollMode: function () { return _mode === 'scroll'; },
|
||||
isNavMode: function () { return _mode === 'nav'; },
|
||||
setUrl: function (u) { if (expandBtn) expandBtn.href = u; },
|
||||
setTitle: function (t) { if (titleEl) titleEl.textContent = t || 'Video'; },
|
||||
syncBtn: syncBtn
|
||||
};
|
||||
})();
|
||||
|
||||
/* ── SPA navigation — swaps <main> content so the mini player video
|
||||
element lives on across page changes without any HLS reconnection ── */
|
||||
(function () {
|
||||
|
||||
function isVideoShowPage(url) {
|
||||
/* /videos/{key} — two path segments, second not a static word */
|
||||
var parts = new URL(url, location.href).pathname
|
||||
.replace(/\/$/, '').split('/').filter(Boolean);
|
||||
if (parts.length !== 2 || parts[0] !== 'videos') return false;
|
||||
var STATIC = { search:1, trending:1, create:1, shorts:1 };
|
||||
return !STATIC[parts[1]];
|
||||
}
|
||||
|
||||
function isInternal(href) {
|
||||
try { return new URL(href, location.href).origin === location.origin; }
|
||||
catch(e) { return false; }
|
||||
}
|
||||
|
||||
function reExecScripts(container) {
|
||||
Array.from(container.querySelectorAll('script')).forEach(function (old) {
|
||||
var n = document.createElement('script');
|
||||
Array.from(old.attributes).forEach(function (a) {
|
||||
n.setAttribute(a.name, a.value);
|
||||
});
|
||||
n.textContent = old.textContent;
|
||||
old.parentNode.replaceChild(n, old);
|
||||
});
|
||||
}
|
||||
|
||||
function updateNavStates(url) {
|
||||
var path = new URL(url, location.href).pathname;
|
||||
document.querySelectorAll('.yt-bottom-nav-item, .yt-sidebar-item[href]')
|
||||
.forEach(function (el) {
|
||||
try {
|
||||
var ep = new URL(el.getAttribute('href') || '', location.href).pathname;
|
||||
el.classList.toggle('active', ep !== '/' && path.startsWith(ep) || ep === path);
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function spaGo(url) {
|
||||
fetch(url, { headers: { 'X-SPA-Nav': '1' }, credentials: 'same-origin' })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var newM = doc.getElementById('main');
|
||||
var curM = document.getElementById('main');
|
||||
if (!newM || !curM) { location.href = url; return; }
|
||||
|
||||
/* Safety: if destination turned out to be a video page, hard-navigate */
|
||||
if (doc.getElementById('ytpWrap') || doc.getElementById('videoPlayer')) {
|
||||
location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = doc.title;
|
||||
curM.className = newM.className;
|
||||
curM.innerHTML = newM.innerHTML;
|
||||
reExecScripts(curM);
|
||||
curM.scrollTop = 0;
|
||||
history.pushState({ spa: true, url: url }, doc.title, url);
|
||||
updateNavStates(url);
|
||||
})
|
||||
.catch(function () { location.href = url; });
|
||||
}
|
||||
|
||||
function stopMiniAndNavigate(url) {
|
||||
var v = document.getElementById('videoPlayer');
|
||||
if (v) v.pause();
|
||||
document.getElementById('ytpMini').style.display = 'none';
|
||||
/* Allow browser to do a normal full-page load */
|
||||
}
|
||||
|
||||
/* ── Intercept link clicks ── */
|
||||
document.addEventListener('click', function (e) {
|
||||
var a = e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
var href = a.getAttribute('href');
|
||||
if (!href || href === '#' || /^(javascript:|mailto:|tel:)/.test(href)) return;
|
||||
if (a.target === '_blank') return;
|
||||
if (!isInternal(href)) return;
|
||||
/* SPA-managed video cards handle their own transitions */
|
||||
if (a.hasAttribute('data-rec-url') || a.hasAttribute('data-pl-id')) return;
|
||||
|
||||
var destUrl = new URL(href, location.href).href;
|
||||
var v = document.getElementById('videoPlayer');
|
||||
var playing = v && (window._ytpWasPlaying || !v.paused);
|
||||
var miniOn = window._miniPlayer && window._miniPlayer.isActive();
|
||||
|
||||
if (!playing && !miniOn) return;
|
||||
|
||||
/* Going to a video page: stop mini, let browser do a full load */
|
||||
if (isVideoShowPage(destUrl)) {
|
||||
if (miniOn) stopMiniAndNavigate(destUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
var raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
|
||||
var state;
|
||||
try { state = JSON.parse(raw); } catch(e) { return; }
|
||||
if (!state || !state.src) return;
|
||||
|
||||
var wrap = document.getElementById('ytpMini');
|
||||
var vid = document.getElementById('ytpMiniVid');
|
||||
var titleEl = document.getElementById('ytpMiniTitle');
|
||||
var playBtn = document.getElementById('ytpMiniPlay');
|
||||
var expandBtn = document.getElementById('ytpMiniExpand');
|
||||
var closeBtn = document.getElementById('ytpMiniClose');
|
||||
if (!wrap || !vid) return;
|
||||
|
||||
titleEl.textContent = state.title || 'Video';
|
||||
expandBtn.href = state.url || '#';
|
||||
vid.volume = state.volume ?? 0.5;
|
||||
vid.muted = state.muted ?? false;
|
||||
|
||||
/* Load HLS.js if needed, then attach source */
|
||||
function startMini(Hls) {
|
||||
if (Hls && Hls.isSupported() && /\.m3u8/.test(state.src)) {
|
||||
var hls = new Hls({ startLevel: -1 });
|
||||
hls.loadSource(state.src);
|
||||
hls.attachMedia(vid);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
vid.currentTime = state.time || 0;
|
||||
vid.play().catch(function () {
|
||||
vid.muted = true;
|
||||
vid.play().catch(function () {});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
vid.src = state.src;
|
||||
vid.addEventListener('loadedmetadata', function () {
|
||||
vid.currentTime = state.time || 0;
|
||||
}, { once: true });
|
||||
vid.play().catch(function () {
|
||||
vid.muted = true;
|
||||
vid.play().catch(function () {});
|
||||
});
|
||||
}
|
||||
wrap.style.display = 'block';
|
||||
/* Activate mini if the video is live in the page (not already in mini) */
|
||||
if (playing && !miniOn) {
|
||||
var title = document.title.replace(/\s*\|.*$/, '').trim();
|
||||
window._miniPlayer.activate(title, location.href);
|
||||
}
|
||||
|
||||
if (window.Hls) {
|
||||
startMini(window.Hls);
|
||||
} else {
|
||||
var s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js';
|
||||
s.onload = function () { startMini(window.Hls); };
|
||||
s.onerror = function () { startMini(null); };
|
||||
document.head.appendChild(s);
|
||||
spaGo(destUrl);
|
||||
}, true);
|
||||
|
||||
/* ── Back / forward button ── */
|
||||
window.addEventListener('popstate', function (e) {
|
||||
var url = location.href;
|
||||
var miniOn = window._miniPlayer && window._miniPlayer.isActive();
|
||||
|
||||
if (isVideoShowPage(url)) {
|
||||
if (miniOn) stopMiniAndNavigate(url);
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Play / pause toggle */
|
||||
vid.addEventListener('play', function () { playBtn.querySelector('i').className = 'bi bi-pause-fill'; });
|
||||
vid.addEventListener('pause', function () { playBtn.querySelector('i').className = 'bi bi-play-fill'; });
|
||||
playBtn.addEventListener('click', function () {
|
||||
if (vid.paused) { vid.play().catch(function(){}); }
|
||||
else { vid.pause(); }
|
||||
});
|
||||
|
||||
/* Expand → navigate to video page */
|
||||
expandBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var t = vid.currentTime;
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
src: state.src, time: t, title: state.title,
|
||||
url: state.url, muted: vid.muted, volume: vid.volume
|
||||
}));
|
||||
window.location.href = state.url;
|
||||
});
|
||||
|
||||
/* Close */
|
||||
closeBtn.addEventListener('click', function () {
|
||||
vid.pause();
|
||||
wrap.style.display = 'none';
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
if (miniOn || (e.state && e.state.spa)) {
|
||||
spaGo(url);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<!-- Extra Mobile Responsive Styles -->
|
||||
<style>
|
||||
/* Touch-friendly improvements */
|
||||
@media (max-width: 480px) {
|
||||
/* Better touch targets */
|
||||
.yt-menu-btn, .yt-icon-btn, .yt-mobile-search-toggle {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Reduce padding on main content */
|
||||
.yt-main {
|
||||
padding: 12px 8px !important;
|
||||
}
|
||||
|
||||
/* Smaller video title */
|
||||
.yt-video-title, .video-title {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* Channel info compact */
|
||||
.channel-info {
|
||||
gap: 8px !important;
|
||||
}
|
||||
.channel-avatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
.channel-name {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.channel-subs {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* Video meta smaller */
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* Action buttons horizontal scroll on mobile */
|
||||
.video-actions {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.yt-action-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Comment improvements */
|
||||
.comment-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
.comment-item > img {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
/* Search input mobile */
|
||||
.yt-search-input {
|
||||
font-size: 14px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Header spacing */
|
||||
.yt-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* User dropdown full width on mobile */
|
||||
.dropdown-menu {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens */
|
||||
@media (max-width: 360px) {
|
||||
.yt-main {
|
||||
padding: 8px 4px !important;
|
||||
}
|
||||
.yt-main.video-view-page {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.yt-header-right .yt-icon-btn:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape mobile */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.yt-sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* 4-column grid on wide screens (≥1440 px) */
|
||||
@media (min-width: 1440px) {
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(4, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Better video player on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.video-container {
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar link padding for touch */
|
||||
@media (hover: none) {
|
||||
.yt-sidebar-link {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.yt-sidebar-link:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.yt-sidebar-link:active {
|
||||
background: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* YouTube-style Bottom Navigation Bar */
|
||||
.yt-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
background: var(--bg-dark);
|
||||
border-top: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* JS will manage transform via visualViewport API */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
transition: color 0.2s;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item.active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item i {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item span {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Mobile: native-app scroll model ─────────────────────────────────
|
||||
Lock html+body so the WINDOW never scrolls.
|
||||
Only .yt-main scrolls, keeping header and bottom nav truly fixed.
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
/* Prevents iOS elastic-scroll bounce on body itself */
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.yt-bottom-nav {
|
||||
display: flex;
|
||||
/* JS transform no longer needed — window never scrolls */
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.yt-main {
|
||||
/* Stretch between header and bottom nav */
|
||||
position: fixed !important;
|
||||
top: 56px !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
margin: 0 !important;
|
||||
padding: 16px !important;
|
||||
padding-bottom: 16px !important;
|
||||
min-height: unset !important;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
body.has-impersonate-bar .yt-main {
|
||||
top: calc(56px + 40px) !important;
|
||||
}
|
||||
|
||||
.yt-main.video-view-page {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Filter bar: remove sticky so it scrolls with content */
|
||||
.yt-filter-bar {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
z-index: auto !important;
|
||||
margin: -16px -16px 16px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1843,7 +1843,13 @@ $headerSocialMap = [
|
||||
@if($playlists && $playlists->count() > 0)
|
||||
<div class="ch-playlists-grid">
|
||||
@foreach($playlists as $playlist)
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="ch-playlist-card">
|
||||
@php
|
||||
$plFirstVid = $playlist->videos->first();
|
||||
$plCardUrl = $plFirstVid
|
||||
? route('videos.show', $plFirstVid) . '?playlist=' . $playlist->share_token
|
||||
: route('playlists.show', $playlist->id);
|
||||
@endphp
|
||||
<a href="{{ $plCardUrl }}" class="ch-playlist-card">
|
||||
<div class="ch-playlist-thumb">
|
||||
@if($playlist->thumbnail_url)
|
||||
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}" loading="lazy">
|
||||
|
||||
@@ -88,6 +88,29 @@
|
||||
.option-content span { font-size: 13px; color: #aaa; }
|
||||
.option-item.active .option-content span { color: #fff; }
|
||||
|
||||
/* Language track accordion (create page) */
|
||||
.lta-create { display: flex; flex-direction: column; gap: 6px; margin-bottom: 0; }
|
||||
.ltac-item { background: #151515; border: 1px solid #333; border-radius: 10px; overflow: hidden; transition: border-color .2s; }
|
||||
.ltac-item.open { border-color: rgba(230,30,30,.45); }
|
||||
.ltac-header { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px; cursor: pointer; user-select: none; gap: 8px; }
|
||||
.ltac-hd-left { display: flex; align-items: center; gap: 9px; flex: 1; min-width: 0; }
|
||||
.ltac-num { width: 22px; height: 22px; border-radius: 50%; background: rgba(230,30,30,.15); border: 1px solid rgba(230,30,30,.3); display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: var(--brand-red); flex-shrink: 0; }
|
||||
.ltac-flag-cell { flex-shrink: 0; }
|
||||
.ltac-name { font-size: 13px; color: #888; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ltac-item.open .ltac-name { color: #e5e5e5; }
|
||||
.ltac-arr { font-size: 12px; color: #555; transition: transform .2s, color .2s; flex-shrink: 0; }
|
||||
.ltac-body { padding: 12px 14px 14px; border-top: 1px solid #2a2a2a; }
|
||||
.ltac-item:not(.open) .ltac-body { display: none; }
|
||||
.ltac-remove { background: none; border: none; color: #555; cursor: pointer; font-size: 13px; padding: 3px 6px; border-radius: 4px; transition: color .15s, background .15s; display: flex; align-items: center; }
|
||||
.ltac-remove:hover { color: var(--brand-red); background: rgba(230,30,30,.1); }
|
||||
.ltac-file-label { display: flex; align-items: center; gap: 8px; background: #121212; border: 1px solid #333; border-radius: 8px; padding: 10px 12px; cursor: pointer; transition: border-color .15s; }
|
||||
.ltac-file-name { font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.ltac-slides-zone { border: 1.5px dashed #333; border-radius: 10px; min-height: 70px; background: #121212; cursor: pointer; transition: border-color .2s; display: flex; align-items: center; justify-content: center; }
|
||||
.ltac-slides-zone:hover { border-color: var(--brand-red); }
|
||||
.ltac-slides-zone.dragover { border-color: var(--brand-red); background: rgba(230,30,30,.05); }
|
||||
.ltac-slides-ph { display: flex; align-items: center; gap: 8px; color: #555; font-size: 13px; padding: 16px; }
|
||||
.ltac-slides-ph i { color: #444; }
|
||||
|
||||
/* Two Column Layout */
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
@@ -145,7 +168,7 @@
|
||||
@csrf
|
||||
|
||||
<!-- Video File Dropzone -->
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="file-dropzone-group">
|
||||
<label class="form-label"><i class="bi bi-camera-video" id="file-label-icon"></i> <span id="file-label-text">Video or Music File *</span></label>
|
||||
<div id="dropzone">
|
||||
<input type="file" name="video" id="video" accept="video/*,audio/mpeg,audio/mp4,audio/aac,audio/flac,audio/wav,.mp3,.m4a,.aac,.flac,.wav" required>
|
||||
@@ -165,16 +188,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" name="title" id="video-title" required class="form-input" placeholder="Enter video title">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea>
|
||||
<!-- Title + Description (video mode) -->
|
||||
<div id="basic-fields-create">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" name="title" id="video-title" class="form-input" placeholder="Enter video title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" id="video-description" rows="4" class="form-textarea" placeholder="Tell viewers about your video (Markdown supported)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail (video) -->
|
||||
@@ -184,7 +207,7 @@
|
||||
<input type="file" name="thumbnail" id="thumbnail" accept="image/*">
|
||||
<div id="thumbnail-default">
|
||||
<i class="bi bi-card-image"></i>
|
||||
<p id="thumbnail-dropzone-hint">Click to select thumbnail (optional, max 20MB)</p>
|
||||
<p id="thumbnail-dropzone-hint">Click to select thumbnail (optional)</p>
|
||||
</div>
|
||||
<div id="thumbnail-info">
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:12px;background:#1f1f1f;border-radius:8px;border:1px solid #333;">
|
||||
@@ -201,28 +224,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slides dropzone (audio only) -->
|
||||
<div class="form-group" id="slides-section" style="display:none;">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-images"></i> Slides <span style="color:var(--brand-red);">*</span>
|
||||
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:6px;">Multiple images will crossfade while playing</span>
|
||||
</label>
|
||||
<div id="slides-dropzone" style="border:2px dashed var(--brand-red);border-radius:10px;padding:12px;cursor:pointer;text-align:center;min-height:72px;">
|
||||
<input type="file" name="slides[]" id="slides-input" accept="image/*" multiple style="display:none;">
|
||||
<div id="slides-default">
|
||||
<i class="bi bi-images" style="font-size:28px;color:var(--text-secondary);"></i>
|
||||
<p style="color:var(--text-secondary);font-size:13px;margin:4px 0 0;">Click or drag to add cover images</p>
|
||||
</div>
|
||||
<div id="slides-preview" style="display:none;">
|
||||
<div id="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:8px;"></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:0 4px;">
|
||||
<span id="slides-count" style="font-size:12px;color:var(--text-secondary);"></span>
|
||||
<button type="button" onclick="removeSlides(event)" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;"><i class="bi bi-x-lg"></i> Clear</button>
|
||||
<!-- Language Tracks accordion (audio mode only) -->
|
||||
<div class="form-group" id="lang-tracks-section-create" style="display:none;">
|
||||
<label class="form-label"><i class="bi bi-translate"></i> Language Tracks</label>
|
||||
|
||||
<div id="lta-create" class="lta-create">
|
||||
|
||||
<!-- Track 1 — always present, primary -->
|
||||
<div class="ltac-item open" id="ltac-t1">
|
||||
<div class="ltac-header" onclick="ltToggleCreate('t1')">
|
||||
<div class="ltac-hd-left">
|
||||
<span class="ltac-num">1</span>
|
||||
<span class="ltac-flag-cell" id="ltac-t1-flag">
|
||||
<span class="fi fi-xx lsd-flag" style="width:22px;height:16px;"></span>
|
||||
</span>
|
||||
<span class="ltac-name" id="ltac-t1-name">Primary — Select language</span>
|
||||
</div>
|
||||
<i class="bi bi-chevron-up ltac-arr" style="color:var(--brand-red);" id="ltac-t1-arr"></i>
|
||||
</div>
|
||||
<div class="ltac-body" id="ltac-t1-body">
|
||||
|
||||
<!-- Audio File Picker -->
|
||||
<div style="margin-bottom:12px;" id="ltc-track1-file-wrap">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;"><i class="bi bi-music-note-beamed"></i> Audio File *</label>
|
||||
<label class="ltac-file-label"
|
||||
onclick="document.getElementById('video').click()"
|
||||
onmouseenter="this.style.borderColor='var(--brand-red)'"
|
||||
onmouseleave="this.style.borderColor='#333'">
|
||||
<i class="bi bi-music-note-beamed" style="color:var(--brand-red);font-size:16px;flex-shrink:0;"></i>
|
||||
<span class="ltac-file-name" id="ltc-track1-fname">Choose audio file…</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Title (audio mode) -->
|
||||
<div style="margin-bottom:12px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Title *</label>
|
||||
<input type="text" id="lt-track1-title-create" class="form-input"
|
||||
style="font-size:13px;" placeholder="Track title…" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Description (audio mode) -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Description</label>
|
||||
<textarea id="lt-track1-desc-create" class="form-textarea"
|
||||
rows="2" style="font-size:13px;min-height:64px;"
|
||||
placeholder="Tell viewers about this track…"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Slides -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
|
||||
<i class="bi bi-images"></i> Cover Slides <span style="color:var(--brand-red);">*</span>
|
||||
<span style="font-weight:400;font-size:12px;color:var(--text-secondary);margin-left:4px;">drag to reorder</span>
|
||||
</label>
|
||||
<div id="slides-zone-ct1" class="ltac-slides-zone"
|
||||
onclick="cSlidesZoneClick(event,'ct1')"
|
||||
ondragover="cSlidesZoneDragover(event,'ct1')"
|
||||
ondragleave="cSlidesZoneDragleave(event,'ct1')"
|
||||
ondrop="cSlidesZoneDrop(event,'ct1')">
|
||||
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-ct1"
|
||||
onchange="handleCSlidesForTrack('ct1',this.files)">
|
||||
<div class="ltac-slides-ph" id="slides-ph-ct1">
|
||||
<i class="bi bi-images" style="font-size:22px;"></i>
|
||||
<span>Click or drag to add cover images</span>
|
||||
</div>
|
||||
<div id="slides-preview-ct1" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
|
||||
<div id="slides-strip-ct1" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span id="slides-count-ct1" style="font-size:12px;color:var(--text-secondary);"></span>
|
||||
<button type="button" onclick="clearCSlidesForTrack(event,'ct1')"
|
||||
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
|
||||
<i class="bi bi-x-lg"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary Language -->
|
||||
<div>
|
||||
<x-language-select
|
||||
name="primary_language"
|
||||
id="primary_language_create"
|
||||
label="Language"
|
||||
placeholder="Select language"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra tracks appended here by JS -->
|
||||
<div id="ltac-extra"></div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="action-btn" onclick="addLangTrackCreate()"
|
||||
style="font-size:13px;margin-top:8px;">
|
||||
<i class="bi bi-plus-circle"></i> <span>Add Language Track</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Video Type -->
|
||||
<div class="options-section">
|
||||
<span class="options-label">Video Type</span>
|
||||
@@ -345,9 +445,16 @@
|
||||
const fileInput = document.getElementById('video');
|
||||
let thumbnailInput = document.getElementById('thumbnail');
|
||||
const thumbnailDropzone = document.getElementById('thumbnail-dropzone');
|
||||
const slidesDropzone = document.getElementById('slides-dropzone');
|
||||
let slidesInput = document.getElementById('slides-input');
|
||||
let currentFile = null;
|
||||
let _pageUploadInProgress = false;
|
||||
|
||||
// Warn before navigating away when a file is selected or upload is running
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (currentFile && !_pageUploadInProgress) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
function formatFileSize(bytes) {
|
||||
@@ -367,10 +474,29 @@
|
||||
document.getElementById('file-label-icon').className = audio ? 'bi bi-music-note-beamed' : 'bi bi-camera-video';
|
||||
document.getElementById('file-label-text').textContent = audio ? 'Music File *' : 'Video or Music File *';
|
||||
|
||||
// Toggle thumbnail vs slides section
|
||||
document.getElementById('thumbnail-section').style.display = audio ? 'none' : '';
|
||||
document.getElementById('slides-section').style.display = audio ? '' : 'none';
|
||||
slidesDropzone.style.borderColor = audio ? 'var(--brand-red)' : '';
|
||||
// Show/hide sections
|
||||
document.getElementById('file-dropzone-group').style.display = audio ? 'none' : '';
|
||||
document.getElementById('ltc-track1-file-wrap').style.display = audio ? '' : 'none';
|
||||
document.getElementById('basic-fields-create').style.display = audio ? 'none' : '';
|
||||
document.getElementById('thumbnail-section').style.display = audio ? 'none' : '';
|
||||
document.getElementById('lang-tracks-section-create').style.display = audio ? '' : 'none';
|
||||
if (audio) {
|
||||
const z = document.getElementById('slides-zone-ct1');
|
||||
if (z && !(_cSlidesData['ct1'] && _cSlidesData['ct1'].length)) z.style.borderColor = 'var(--brand-red)';
|
||||
}
|
||||
|
||||
// Swap name attributes so only the active set submits
|
||||
const outerTitle = document.getElementById('video-title');
|
||||
const outerDesc = document.getElementById('video-description');
|
||||
const innerTitle = document.getElementById('lt-track1-title-create');
|
||||
const innerDesc = document.getElementById('lt-track1-desc-create');
|
||||
if (audio) {
|
||||
outerTitle.removeAttribute('name'); outerDesc.removeAttribute('name');
|
||||
innerTitle.setAttribute('name', 'title'); innerDesc.setAttribute('name', 'description');
|
||||
} else {
|
||||
outerTitle.setAttribute('name', 'title'); outerDesc.setAttribute('name', 'description');
|
||||
innerTitle.removeAttribute('name'); innerDesc.removeAttribute('name');
|
||||
}
|
||||
|
||||
if (audio) {
|
||||
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
|
||||
@@ -399,7 +525,6 @@
|
||||
function handleFileSelect(input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
const file = input.files[0];
|
||||
const maxSize = 512 * 1024 * 1024;
|
||||
const validVideoTypes = ['video/mp4','video/webm','video/ogg','video/quicktime','video/x-msvideo','video/x-flv','video/x-matroska'];
|
||||
const validVideoExts = ['.mp4','.webm','.ogg','.mov','.avi','.wmv','.flv','.mkv'];
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
@@ -410,20 +535,22 @@
|
||||
showError('Invalid format. Accepted: MP4, MOV, AVI, WebM, MKV, MP3, M4A, AAC, FLAC, WAV.');
|
||||
input.value = ''; return;
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
showError('File size exceeds 512MB limit.');
|
||||
input.value = ''; return;
|
||||
}
|
||||
|
||||
currentFile = file;
|
||||
setAudioMode(audio);
|
||||
|
||||
const titleWithoutExt = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
|
||||
document.getElementById('video-title').value = titleWithoutExt;
|
||||
if (audio) {
|
||||
document.getElementById('lt-track1-title-create').value = titleWithoutExt;
|
||||
} else {
|
||||
document.getElementById('video-title').value = titleWithoutExt;
|
||||
}
|
||||
document.getElementById('filename').textContent = file.name;
|
||||
document.getElementById('filesize').textContent = formatFileSize(file.size);
|
||||
document.getElementById('dropzone-default').style.display = 'none';
|
||||
document.getElementById('file-info').classList.add('active');
|
||||
const cFname = document.getElementById('ltc-track1-fname');
|
||||
if (cFname) cFname.textContent = file.name;
|
||||
}
|
||||
|
||||
function removeVideo(e) {
|
||||
@@ -431,7 +558,11 @@
|
||||
fileInput.value = ''; currentFile = null;
|
||||
document.getElementById('dropzone-default').style.display = 'block';
|
||||
document.getElementById('file-info').classList.remove('active');
|
||||
const cFname = document.getElementById('ltc-track1-fname');
|
||||
if (cFname) cFname.textContent = 'Choose audio file…';
|
||||
document.getElementById('video-title').value = '';
|
||||
document.getElementById('lt-track1-title-create').value = '';
|
||||
document.getElementById('lt-track1-desc-create').value = '';
|
||||
setAudioMode(false);
|
||||
}
|
||||
|
||||
@@ -445,6 +576,8 @@
|
||||
_thumbClickTs = now;
|
||||
if (typeof window.openCropperModal_thumb_create_mobile === 'function') {
|
||||
window.openCropperModal_thumb_create_mobile();
|
||||
const internal = document.getElementById('tcInput_thumb_create_mobile');
|
||||
if (internal) internal.click();
|
||||
} else {
|
||||
thumbnailInput.click();
|
||||
}
|
||||
@@ -487,54 +620,119 @@
|
||||
document.getElementById('thumbnail-info').classList.remove('active');
|
||||
}
|
||||
|
||||
// ── Slides dropzone (audio mode) ─────────────────────────
|
||||
slidesDropzone.addEventListener('click', e => { if (e.target.closest('button')) return; slidesInput.click(); });
|
||||
slidesInput.addEventListener('change', function() { handleSlidesSelect(this); });
|
||||
slidesDropzone.addEventListener('dragover', e => { e.preventDefault(); slidesDropzone.classList.add('dragover'); });
|
||||
slidesDropzone.addEventListener('dragleave', () => slidesDropzone.classList.remove('dragover'));
|
||||
slidesDropzone.addEventListener('drop', e => {
|
||||
e.preventDefault(); slidesDropzone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length) { slidesInput.files = e.dataTransfer.files; handleSlidesSelect(slidesInput); }
|
||||
});
|
||||
// ── Per-track slides management (create page) ────────────
|
||||
const _cSlidesData = {}; // trackId → File[]
|
||||
let _cSlidesDragSrc = null;
|
||||
|
||||
function handleSlidesSelect(input) {
|
||||
if (!input.files || !input.files.length) return;
|
||||
const files = Array.from(input.files);
|
||||
const strip = document.getElementById('slides-strip');
|
||||
strip.innerHTML = '';
|
||||
files.forEach(f => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = document.createElement('img');
|
||||
img.src = e.target.result;
|
||||
img.style.cssText = 'width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.15);';
|
||||
strip.appendChild(img);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
});
|
||||
document.getElementById('slides-count').textContent =
|
||||
files.length === 1 ? '1 image — static cover' : files.length + ' images — will crossfade';
|
||||
document.getElementById('slides-default').style.display = 'none';
|
||||
document.getElementById('slides-preview').style.display = '';
|
||||
slidesDropzone.style.borderColor = '#22c55e';
|
||||
function cSlidesZoneClick(e, trackId) {
|
||||
if (e.target.closest('button')) return;
|
||||
document.getElementById('slides-input-' + trackId)?.click();
|
||||
}
|
||||
function cSlidesZoneDragover(e, trackId) {
|
||||
if (_cSlidesDragSrc) { e.preventDefault(); return; }
|
||||
if (Array.from(e.dataTransfer.types || []).includes('Files')) {
|
||||
e.preventDefault();
|
||||
document.getElementById('slides-zone-' + trackId)?.classList.add('dragover');
|
||||
}
|
||||
}
|
||||
function cSlidesZoneDragleave(e, trackId) {
|
||||
document.getElementById('slides-zone-' + trackId)?.classList.remove('dragover');
|
||||
}
|
||||
function cSlidesZoneDrop(e, trackId) {
|
||||
e.preventDefault();
|
||||
document.getElementById('slides-zone-' + trackId)?.classList.remove('dragover');
|
||||
if (_cSlidesDragSrc) return;
|
||||
if (e.dataTransfer.files.length) handleCSlidesForTrack(trackId, e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function removeSlides(e) {
|
||||
function handleCSlidesForTrack(trackId, fileList) {
|
||||
if (!fileList || !fileList.length) return;
|
||||
if (!_cSlidesData[trackId]) _cSlidesData[trackId] = [];
|
||||
for (const f of Array.from(fileList)) {
|
||||
if (_cSlidesData[trackId].length >= 10) break;
|
||||
_cSlidesData[trackId].push(f);
|
||||
}
|
||||
renderCSlidesStrip(trackId);
|
||||
}
|
||||
|
||||
function renderCSlidesStrip(trackId) {
|
||||
const files = _cSlidesData[trackId] || [];
|
||||
const strip = document.getElementById('slides-strip-' + trackId);
|
||||
const ph = document.getElementById('slides-ph-' + trackId);
|
||||
const prev = document.getElementById('slides-preview-' + trackId);
|
||||
const cnt = document.getElementById('slides-count-' + trackId);
|
||||
const zone = document.getElementById('slides-zone-' + trackId);
|
||||
if (!strip) return;
|
||||
strip.innerHTML = '';
|
||||
if (!files.length) {
|
||||
if (ph) ph.style.display = ''; if (prev) prev.style.display = 'none';
|
||||
if (zone) zone.style.borderColor = ''; return;
|
||||
}
|
||||
files.forEach((f, index) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
|
||||
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = trackId;
|
||||
wrap.addEventListener('dragstart', function(ev) {
|
||||
_cSlidesDragSrc = { trackId, index: parseInt(this.dataset.index) };
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
setTimeout(() => { this.style.opacity = '0.4'; }, 0);
|
||||
});
|
||||
wrap.addEventListener('dragend', function() { _cSlidesDragSrc = null; this.style.opacity = ''; });
|
||||
wrap.addEventListener('dragover', function(ev) {
|
||||
if (!_cSlidesDragSrc || _cSlidesDragSrc.trackId !== trackId) return;
|
||||
ev.preventDefault(); this.style.outline = '2px solid var(--brand-red)';
|
||||
});
|
||||
wrap.addEventListener('dragleave', function() { this.style.outline = ''; });
|
||||
wrap.addEventListener('drop', function(ev) {
|
||||
ev.preventDefault(); ev.stopPropagation(); this.style.outline = '';
|
||||
if (!_cSlidesDragSrc || _cSlidesDragSrc.trackId !== trackId) return;
|
||||
const from = _cSlidesDragSrc.index, to = parseInt(this.dataset.index);
|
||||
if (from === to) return;
|
||||
const arr = _cSlidesData[trackId];
|
||||
const [moved] = arr.splice(from, 1); arr.splice(to, 0, moved);
|
||||
renderCSlidesStrip(trackId);
|
||||
});
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.style.cssText = 'position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:var(--brand-red);border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
|
||||
removeBtn.innerHTML = '<i class="bi bi-x"></i>';
|
||||
removeBtn.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation(); _cSlidesData[trackId].splice(index, 1); renderCSlidesStrip(trackId);
|
||||
});
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText = 'width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;';
|
||||
img.alt = '';
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev2 => { img.src = ev2.target.result; };
|
||||
reader.readAsDataURL(f);
|
||||
wrap.appendChild(img); wrap.appendChild(removeBtn); strip.appendChild(wrap);
|
||||
});
|
||||
if (cnt) cnt.textContent = files.length === 1 ? '1 image — static cover' : files.length + ' images — will crossfade';
|
||||
if (ph) ph.style.display = 'none'; if (prev) prev.style.display = '';
|
||||
if (zone) zone.style.borderColor = '#22c55e';
|
||||
}
|
||||
|
||||
function clearCSlidesForTrack(e, trackId) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const fresh = slidesInput.cloneNode(false);
|
||||
slidesInput.parentNode.replaceChild(fresh, slidesInput);
|
||||
slidesInput = fresh;
|
||||
slidesInput.addEventListener('change', function() { handleSlidesSelect(this); });
|
||||
document.getElementById('slides-default').style.display = 'block';
|
||||
document.getElementById('slides-preview').style.display = 'none';
|
||||
slidesDropzone.style.borderColor = 'var(--brand-red)';
|
||||
_cSlidesData[trackId] = [];
|
||||
const input = document.getElementById('slides-input-' + trackId);
|
||||
if (input) {
|
||||
const fresh = input.cloneNode(false);
|
||||
fresh.addEventListener('change', function() { handleCSlidesForTrack(trackId, this.files); });
|
||||
input.parentNode.replaceChild(fresh, input);
|
||||
}
|
||||
renderCSlidesStrip(trackId);
|
||||
}
|
||||
|
||||
// ── Option selectors ─────────────────────────────────────
|
||||
document.querySelectorAll('#type-options .option-item').forEach(item => {
|
||||
item.querySelector('input').addEventListener('change', () => {
|
||||
document.querySelectorAll('#type-options .option-item').forEach(o => o.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
item.addEventListener('click', function() {
|
||||
document.querySelectorAll('#type-options .option-item').forEach(o => {
|
||||
o.classList.toggle('active', o.dataset.type === this.dataset.type);
|
||||
const inp = o.querySelector('input');
|
||||
if (inp) inp.checked = (o.dataset.type === this.dataset.type);
|
||||
});
|
||||
setAudioMode(this.dataset.type === 'music');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('#visibility-options .option-item').forEach(item => {
|
||||
@@ -557,21 +755,35 @@
|
||||
|
||||
if (!currentFile) { showError('Please select a video or audio file.'); return; }
|
||||
|
||||
const title = document.getElementById('video-title').value.trim();
|
||||
const isAudio = isAudioFile(currentFile);
|
||||
const titleEl = isAudio ? document.getElementById('lt-track1-title-create') : document.getElementById('video-title');
|
||||
const title = titleEl.value.trim();
|
||||
if (!title) { showError('Please enter a title.'); return; }
|
||||
|
||||
if (isAudioFile(currentFile)) {
|
||||
if (!slidesInput.files || !slidesInput.files.length) {
|
||||
const _isAudioSubmitC = isAudioFile(currentFile);
|
||||
if (_isAudioSubmitC) {
|
||||
const t1Slides = _cSlidesData['ct1'] || [];
|
||||
if (!t1Slides.length) {
|
||||
showError('At least one cover image is required for audio tracks.');
|
||||
slidesDropzone.style.borderColor = 'var(--brand-red)';
|
||||
const z = document.getElementById('slides-zone-ct1');
|
||||
if (z) z.style.borderColor = 'var(--brand-red)';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData(this);
|
||||
if (_isAudioSubmitC) {
|
||||
(_cSlidesData['ct1'] || []).forEach(f => formData.append('slides[]', f));
|
||||
for (const [tid, files] of Object.entries(_cSlidesData)) {
|
||||
if (tid === 'ct1') continue;
|
||||
const n = parseInt(tid.replace('ce', ''));
|
||||
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
|
||||
}
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 1800000; // 30 min
|
||||
xhr.timeout = 0; // no timeout — unlimited file size
|
||||
|
||||
_pageUploadInProgress = true;
|
||||
document.getElementById('progress-container').classList.add('active');
|
||||
document.getElementById('submit-btn').disabled = true;
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
|
||||
@@ -593,8 +805,12 @@
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success) {
|
||||
document.getElementById('progress-text').textContent = 'Done! Redirecting...';
|
||||
window.location.href = res.redirect;
|
||||
document.getElementById('progress-container').classList.remove('active');
|
||||
const sm = document.getElementById('status-message');
|
||||
sm.innerHTML = '<i class="bi bi-check-circle-fill"></i> Upload successful! Taking you to your video…';
|
||||
sm.className = 'success';
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-check-lg"></i> <span>Uploaded!</span>';
|
||||
setTimeout(() => { window.location.href = res.redirect; }, 1800);
|
||||
} else {
|
||||
showError(res.message || 'Upload failed.');
|
||||
}
|
||||
@@ -613,7 +829,7 @@
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => showError('Upload failed. Check your connection.'));
|
||||
xhr.addEventListener('timeout', () => showError('Upload timed out. The file may be too large.'));
|
||||
xhr.addEventListener('timeout', () => showError('Upload timed out.'));
|
||||
|
||||
xhr.open('POST', '{{ route("videos.store") }}');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
|
||||
@@ -621,13 +837,196 @@
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
_pageUploadInProgress = false;
|
||||
const status = document.getElementById('status-message');
|
||||
status.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> ' + message;
|
||||
status.className = 'error';
|
||||
document.getElementById('submit-btn').disabled = false;
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-cloud-arrow-up-fill"></i> <span>Upload Video</span>';
|
||||
const isAudio = document.querySelector('#type-options .option-item[data-type="music"]')?.classList.contains('active');
|
||||
document.getElementById('submit-btn').innerHTML =
|
||||
'<i class="bi bi-cloud-arrow-up-fill"></i> <span>' + (isAudio ? 'Upload Track' : 'Upload Video') + '</span>';
|
||||
document.getElementById('progress-container').classList.remove('active');
|
||||
}
|
||||
|
||||
// ── Language track accordion (create page) ───────────────
|
||||
const LANG_OPTIONS_CREATE = @json(\App\Data\Languages::forLanguage());
|
||||
let _ltCreateExtraCounter = 0;
|
||||
|
||||
function _ltcBuildOptsHtml() {
|
||||
return LANG_OPTIONS_CREATE.map(o =>
|
||||
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
|
||||
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
|
||||
<span class="csd-opt-main">${o.label}</span>
|
||||
<span class="csd-opt-sub">${o.native}</span>
|
||||
</li>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function ltToggleCreate(trackId) {
|
||||
const item = document.getElementById('ltac-' + trackId);
|
||||
const arr = document.getElementById('ltac-' + trackId + '-arr');
|
||||
if (!item) return;
|
||||
const isOpen = item.classList.contains('open');
|
||||
if (isOpen) {
|
||||
item.classList.remove('open');
|
||||
if (arr) { arr.style.transform = ''; arr.style.color = ''; }
|
||||
} else {
|
||||
item.classList.add('open');
|
||||
if (arr) { arr.style.transform = 'rotate(180deg)'; arr.style.color = 'var(--brand-red)'; }
|
||||
}
|
||||
}
|
||||
|
||||
function updateLtacHeader(trackId, langCode) {
|
||||
const lang = LANG_OPTIONS_CREATE.find(o => o.value === langCode);
|
||||
if (!lang) return;
|
||||
const flagEl = document.getElementById('ltac-' + trackId + '-flag');
|
||||
const nameEl = document.getElementById('ltac-' + trackId + '-name');
|
||||
if (flagEl) flagEl.innerHTML = `<span class="fi fi-${lang.flag} lsd-flag" style="width:22px;height:16px;"></span>`;
|
||||
if (nameEl) {
|
||||
const prefix = trackId === 't1' ? 'Primary' : 'Track ' + (parseInt(trackId.replace('e', '')) + 1);
|
||||
nameEl.textContent = prefix + ' — ' + lang.label;
|
||||
}
|
||||
}
|
||||
|
||||
function ltRemoveCreate(e, trackId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const item = document.getElementById('ltac-' + trackId);
|
||||
if (item) item.remove();
|
||||
delete _cSlidesData['c' + trackId];
|
||||
}
|
||||
|
||||
function addLangTrackCreate() {
|
||||
const n = ++_ltCreateExtraCounter;
|
||||
const trackNum = n + 1;
|
||||
const uid = 'ltce_' + n;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ltac-item open';
|
||||
item.id = 'ltac-e' + n;
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="ltac-header" onclick="ltToggleCreate('e${n}')">
|
||||
<div class="ltac-hd-left">
|
||||
<span class="ltac-num">${trackNum}</span>
|
||||
<span class="ltac-flag-cell" id="ltac-e${n}-flag">
|
||||
<span class="fi fi-xx lsd-flag" style="width:22px;height:16px;"></span>
|
||||
</span>
|
||||
<span class="ltac-name" id="ltac-e${n}-name">Track ${trackNum} — Select language</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<button type="button" class="ltac-remove" onclick="ltRemoveCreate(event,'e${n}')" title="Remove track">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<i class="bi bi-chevron-up ltac-arr" style="color:var(--brand-red);" id="ltac-e${n}-arr"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ltac-body" id="ltac-e${n}-body">
|
||||
<div style="margin-bottom:12px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;"><i class="bi bi-music-note-beamed"></i> Audio File</label>
|
||||
<label class="ltac-file-label"
|
||||
onmouseenter="this.style.borderColor='var(--brand-red)'"
|
||||
onmouseleave="this.style.borderColor='#333'">
|
||||
<i class="bi bi-music-note-beamed" style="color:var(--brand-red);font-size:16px;flex-shrink:0;"></i>
|
||||
<span class="ltac-file-name" id="ltac-e${n}-fname">Choose audio file…</span>
|
||||
<input type="file" name="extra_track_files[]"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('ltac-e${n}-fname').textContent=this.files[0]?.name||'Choose audio file…'">
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
|
||||
Localized Title <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional</span>
|
||||
</label>
|
||||
<input type="text" name="extra_track_titles[]" class="form-input"
|
||||
style="font-size:13px;" placeholder="Title in this language…">
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
|
||||
Localized Description <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional</span>
|
||||
</label>
|
||||
<textarea name="extra_track_descriptions[]" class="form-textarea"
|
||||
rows="2" style="font-size:13px;min-height:64px;"
|
||||
placeholder="Description in this language…"></textarea>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">
|
||||
Cover Slides <span style="font-weight:400;font-size:12px;color:var(--text-secondary);">optional · drag to reorder</span>
|
||||
</label>
|
||||
<div id="slides-zone-ce${n}" class="ltac-slides-zone"
|
||||
onclick="cSlidesZoneClick(event,'ce${n}')"
|
||||
ondragover="cSlidesZoneDragover(event,'ce${n}')"
|
||||
ondragleave="cSlidesZoneDragleave(event,'ce${n}')"
|
||||
ondrop="cSlidesZoneDrop(event,'ce${n}')">
|
||||
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-ce${n}"
|
||||
onchange="handleCSlidesForTrack('ce${n}',this.files)">
|
||||
<div class="ltac-slides-ph" id="slides-ph-ce${n}">
|
||||
<i class="bi bi-images" style="font-size:22px;"></i>
|
||||
<span>Click or drag to add cover images</span>
|
||||
</div>
|
||||
<div id="slides-preview-ce${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
|
||||
<div id="slides-strip-ce${n}" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span id="slides-count-ce${n}" style="font-size:12px;color:var(--text-secondary);"></span>
|
||||
<button type="button" onclick="clearCSlidesForTrack(event,'ce${n}')"
|
||||
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
|
||||
<i class="bi bi-x-lg"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-size:13px;margin-bottom:6px;">Language</label>
|
||||
<div class="csd-wrap" id="${uid}">
|
||||
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
|
||||
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
|
||||
<span class="csd-val ph">Select language</span>
|
||||
<i class="bi bi-chevron-down csd-arr"></i>
|
||||
</button>
|
||||
<div class="csd-panel" hidden role="listbox">
|
||||
<div class="csd-srch"><i class="bi bi-search"></i><input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false"></div>
|
||||
<ul class="csd-list">${_ltcBuildOptsHtml()}</ul>
|
||||
<p class="csd-empty" hidden>No results</p>
|
||||
</div>
|
||||
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('ltac-extra').appendChild(item);
|
||||
_cSlidesData['ce' + n] = [];
|
||||
|
||||
if (window.CSD) new CSD(uid);
|
||||
|
||||
const hiddenInput = document.getElementById('csd_v_' + uid);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.dataset.trackId = 'e' + n;
|
||||
hiddenInput.addEventListener('change', function() {
|
||||
updateLtacHeader('e' + n, this.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Wire primary language select → update track 1 header
|
||||
;(function() {
|
||||
const plInput = document.getElementById('primary_language_create');
|
||||
if (plInput) {
|
||||
plInput.dataset.trackId = 't1';
|
||||
plInput.addEventListener('change', function() {
|
||||
updateLtacHeader('t1', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Delegated fallback: catches any language hidden-input change within the tracks section
|
||||
const section = document.getElementById('lang-tracks-section-create');
|
||||
if (section) {
|
||||
section.addEventListener('change', function(e) {
|
||||
const tid = e.target.dataset && e.target.dataset.trackId;
|
||||
if (tid && e.target.value) updateLtacHeader(tid, e.target.value);
|
||||
});
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
<x-image-cropper
|
||||
id="thumb_create_mobile"
|
||||
|
||||
@@ -246,6 +246,63 @@
|
||||
<span id="ep-slides-hint" style="font-size:12px;color:var(--text-secondary);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Primary Language (audio only) --}}
|
||||
<div class="form-group">
|
||||
<x-language-select
|
||||
name="primary_language"
|
||||
id="ep_primary_language"
|
||||
label="Track Language"
|
||||
placeholder="Select language"
|
||||
value="{{ $video->language ?? '' }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{-- Language Tracks Manager (audio only) --}}
|
||||
<div class="form-group">
|
||||
<label class="form-label"><i class="bi bi-translate"></i> Language Tracks</label>
|
||||
|
||||
{{-- Existing tracks --}}
|
||||
<div id="ep-tracks-existing" style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px;">
|
||||
@forelse($video->audioTracks as $track)
|
||||
<div style="display:flex;flex-direction:column;gap:6px;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 12px;" data-track-id="{{ $track->id }}" id="ep-track-{{ $track->id }}">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<span style="font-size:11px;font-weight:700;background:rgba(255,255,255,.1);padding:2px 6px;border-radius:4px;font-family:monospace;">{{ strtoupper($track->language) }}</span>
|
||||
<span style="flex:1;font-size:13px;color:#999;">{{ $track->label }}</span>
|
||||
<a href="{{ route('videos.audio-track', ['video' => $video, 'track' => $track->id]) }}" target="_blank" style="font-size:12px;color:#888;" title="Preview"><i class="bi bi-play-circle"></i></a>
|
||||
<input type="hidden" name="delete_track_ids[]" id="ep-del-{{ $track->id }}" value="" disabled>
|
||||
<button type="button" onclick="_epMarkDelTrack({{ $track->id }}, this)" style="background:none;border:none;color:#888;cursor:pointer;padding:2px 4px;font-size:14px;" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" name="track_title_updates[{{ $track->id }}]"
|
||||
value="{{ $track->title ?? '' }}"
|
||||
placeholder="Title in this language…"
|
||||
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;margin-bottom:6px;">
|
||||
<textarea name="track_description_updates[{{ $track->id }}]"
|
||||
placeholder="Description in this language…" rows="2"
|
||||
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;resize:none;margin-bottom:6px;">{{ $track->description ?? '' }}</textarea>
|
||||
<label style="display:flex;align-items:center;gap:8px;background:#0d0d0d;border:1px solid #222;border-radius:6px;padding:8px 10px;cursor:pointer;"
|
||||
onmouseenter="this.style.borderColor='#e61e1e'" onmouseleave="this.style.borderColor='#222'">
|
||||
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:14px;flex-shrink:0;"></i>
|
||||
<span style="font-size:12px;color:#888;flex:1;" id="ep-track-fname-{{ $track->id }}">Keep existing / choose new file…</span>
|
||||
<input type="file" name="track_file_updates[{{ $track->id }}]"
|
||||
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="document.getElementById('ep-track-fname-{{ $track->id }}').textContent=this.files[0]?.name||'Keep existing / choose new file…'">
|
||||
</label>
|
||||
</div>
|
||||
@empty
|
||||
<p id="ep-tracks-empty-msg" style="font-size:12px;color:#888;margin:0 0 4px;">No extra language tracks yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- New tracks to add --}}
|
||||
<div id="ep-tracks-new-list" style="display:flex;flex-direction:column;gap:8px;margin-bottom:10px;"></div>
|
||||
|
||||
<button type="button" class="action-btn" onclick="addEpTrackRow(event)" style="font-size:13px;">
|
||||
<i class="bi bi-plus-circle"></i> <span>Add Language Track</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Video Type --}}
|
||||
@@ -372,6 +429,8 @@
|
||||
if (e.target.closest('.btn-remove')) return;
|
||||
if (typeof window.openCropperModal_thumb_edit_mobile === 'function') {
|
||||
window.openCropperModal_thumb_edit_mobile();
|
||||
const internal = document.getElementById('tcInput_thumb_edit_mobile');
|
||||
if (internal) internal.click();
|
||||
} else {
|
||||
editThumbInput.click();
|
||||
}
|
||||
@@ -536,6 +595,9 @@
|
||||
@if($video->isAudioOnly())
|
||||
// Append newly added slide files
|
||||
epSlidesData.filter(s => s.file).forEach(s => formData.append('slides_add[]', s.file, s.file.name));
|
||||
|
||||
// Append new extra track files (already have name="extra_track_files[]" in DOM)
|
||||
// delete_track_ids are already wired via enabled hidden inputs
|
||||
@endif
|
||||
|
||||
fetch('{{ route("videos.update", $video) }}', {
|
||||
@@ -561,6 +623,80 @@
|
||||
.catch(() => showEditError('Something went wrong. Please try again.'));
|
||||
});
|
||||
|
||||
@if($video->isAudioOnly())
|
||||
// ── Language track management ─────────────────────────────────────────────
|
||||
const EP_LANG_OPTIONS = @json(\App\Data\Languages::forLanguage());
|
||||
let _epTrackCounter = 0;
|
||||
|
||||
function _epMarkDelTrack(trackId, btn) {
|
||||
const hiddenInput = document.getElementById('ep-del-' + trackId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = trackId;
|
||||
hiddenInput.disabled = false;
|
||||
}
|
||||
btn.closest('[data-track-id]').remove();
|
||||
const existing = document.getElementById('ep-tracks-existing');
|
||||
if (existing && existing.querySelectorAll('[data-track-id]').length === 0) {
|
||||
let msg = document.getElementById('ep-tracks-empty-msg');
|
||||
if (!msg) {
|
||||
msg = document.createElement('p');
|
||||
msg.id = 'ep-tracks-empty-msg';
|
||||
msg.style.cssText = 'font-size:12px;color:#888;margin:0 0 4px;';
|
||||
msg.textContent = 'No extra language tracks yet.';
|
||||
existing.appendChild(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildLangSelectHtmlEp(uid) {
|
||||
const opts = EP_LANG_OPTIONS.map(o =>
|
||||
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false">
|
||||
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span>
|
||||
<span class="csd-opt-main">${o.label}</span>
|
||||
<span class="csd-opt-sub">${o.native}</span>
|
||||
</li>`
|
||||
).join('');
|
||||
return `<div class="csd-wrap" id="csd_${uid}">
|
||||
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
|
||||
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
|
||||
<span class="csd-val ph">Select language</span>
|
||||
<i class="bi bi-chevron-down csd-arr"></i>
|
||||
</button>
|
||||
<div class="csd-panel" hidden role="listbox">
|
||||
<div class="csd-srch"><i class="bi bi-search"></i><input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false"></div>
|
||||
<ul class="csd-list">${opts}</ul>
|
||||
<p class="csd-empty" hidden>No results</p>
|
||||
</div>
|
||||
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function addEpTrackRow(e) {
|
||||
if (e) e.preventDefault();
|
||||
const uid = 'epet_' + (++_epTrackCounter);
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;flex-direction:column;gap:8px;background:#151515;border:1px solid #333;border-radius:8px;padding:10px 12px;';
|
||||
row.innerHTML = `
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div style="flex:1;min-width:0;">${buildLangSelectHtmlEp(uid)}</div>
|
||||
<button type="button" onclick="this.closest('[style*=flex-direction]').remove();" style="background:none;border:none;color:#888;cursor:pointer;padding:4px;font-size:16px;flex-shrink:0;" title="Remove">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" name="extra_track_titles[]"
|
||||
placeholder="Title in this language…"
|
||||
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;">
|
||||
<label style="display:flex;align-items:center;gap:8px;background:#121212;border:1px solid #333;border-radius:6px;padding:8px 10px;cursor:pointer;">
|
||||
<i class="bi bi-music-note-beamed" style="color:var(--brand-red);font-size:16px;"></i>
|
||||
<span class="et-filename-${uid}" style="font-size:12px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;">Choose audio file</span>
|
||||
<input type="file" name="extra_track_files[]" accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
|
||||
onchange="this.parentNode.querySelector('.et-filename-${uid}').textContent=this.files[0]?.name||'Choose audio file';">
|
||||
</label>`;
|
||||
document.getElementById('ep-tracks-new-list').appendChild(row);
|
||||
if (window.CSD) new CSD('csd_' + uid);
|
||||
}
|
||||
@endif
|
||||
|
||||
function showEditError(message) {
|
||||
const status = document.getElementById('edit-status');
|
||||
status.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> ' + message;
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
@php
|
||||
$audioUrl = route('videos.stream', $video);
|
||||
$audioUrl = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
|
||||
$coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png');
|
||||
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
|
||||
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
||||
$slideUrls = $video->slides->count() > 1
|
||||
? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()
|
||||
: [];
|
||||
// Build all-tracks list: primary first, then extra language tracks (skip extras that duplicate primary language)
|
||||
$primaryLang = $video->language ?? 'default';
|
||||
$allLangData = \App\Data\Languages::all();
|
||||
$primaryFlag = $allLangData[$primaryLang]['flag'] ?? null;
|
||||
$allAudioTracks = collect([[
|
||||
'id' => 0,
|
||||
'language' => $primaryLang,
|
||||
'label' => $video->language ? strtoupper($video->language) : 'Default',
|
||||
'flag' => $primaryFlag,
|
||||
'stream_url' => $audioUrl,
|
||||
'title' => $video->title,
|
||||
'description' => $video->description ?? '',
|
||||
'dl_url' => route('videos.downloadMp3', $video),
|
||||
]])->concat($video->audioTracks->map(fn($t) => [
|
||||
'id' => $t->id,
|
||||
'language' => $t->language,
|
||||
'label' => $t->label,
|
||||
'flag' => $allLangData[$t->language]['flag'] ?? null,
|
||||
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
|
||||
'title' => $t->title ?? '',
|
||||
'description' => $t->description ?? '',
|
||||
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
|
||||
]));
|
||||
$hasMultipleTracks = $allAudioTracks->count() > 1;
|
||||
@endphp
|
||||
|
||||
<div class="ytp-wrap" id="ytpWrap">
|
||||
@@ -83,6 +107,34 @@
|
||||
|
||||
<div class="ytp-right-controls">
|
||||
|
||||
{{-- Language flag — always in DOM; hidden only when no language/flag is set --}}
|
||||
<div class="ytp-lang-wrap" id="ytpLangWrap"@if(!$primaryFlag) style="display:none"@endif>
|
||||
<button class="ytp-button ytp-lang-btn" id="ytpLangBtn" title="Language">
|
||||
@if($primaryFlag)
|
||||
<span class="fi fi-{{ $primaryFlag }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>
|
||||
@else
|
||||
<svg viewBox="0 0 24 24" style="width:22px;height:22px;fill:#fff;"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.9 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2s.07-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.66-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/></svg>
|
||||
@endif
|
||||
</button>
|
||||
<div class="ytp-lang-popup" id="ytpLangPopup">
|
||||
<div class="ytp-lang-popup-hdr">Language</div>
|
||||
@foreach($allAudioTracks as $track)
|
||||
<div class="ytp-lang-option {{ $loop->first ? 'active' : '' }}"
|
||||
data-lang-id="{{ $track['id'] }}"
|
||||
data-lang-url="{{ $track['stream_url'] }}"
|
||||
data-lang-label="{{ $track['label'] }}"
|
||||
data-lang-flag="{{ $track['flag'] ?? '' }}"
|
||||
data-lang-title="{{ $track['title'] ?? '' }}"
|
||||
data-lang-description="{{ $track['description'] ?? '' }}"
|
||||
data-lang-dl-url="{{ $track['dl_url'] }}">
|
||||
<span class="fi fi-{{ $track['flag'] ?: 'xx' }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;"></span>
|
||||
<span class="ytp-lang-opt-label">{{ $track['label'] }}</span>
|
||||
<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ytp-settings-wrap" id="ytpSettingsWrap">
|
||||
<button class="ytp-button ytp-settings-btn" id="ytpSettingsBtn" title="Settings">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
@@ -106,16 +158,14 @@
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
{{-- Loop toggle --}}
|
||||
<div class="ytp-settings-item ytp-toggle-row" id="ytpLoopRow">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||||
<span>Loop</span>
|
||||
<span class="ytp-settings-val ytp-toggle-val" id="ytpLoopVal">Off</span>
|
||||
<div class="ytp-toggle-switch"><div class="ytp-toggle-thumb"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loop — standalone button, outside gear --}}
|
||||
<button class="ytp-button ytp-loop-btn" id="ytpLoopBtn" title="Loop">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||||
</button>
|
||||
|
||||
{{-- Bars visualiser toggle --}}
|
||||
<button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off">
|
||||
<svg viewBox="0 0 24 24">
|
||||
@@ -147,17 +197,16 @@
|
||||
.audio-cover-img {
|
||||
position: absolute;
|
||||
inset: 0; width: 100%; height: 100%;
|
||||
object-fit: cover; display: block;
|
||||
object-fit: contain; display: block;
|
||||
}
|
||||
|
||||
/* Slideshow */
|
||||
.slideshow-wrap { position: absolute; inset: 0; background: #000; }
|
||||
.slide-img {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
object-fit: cover; display: block;
|
||||
object-fit: contain; display: block;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
.slide-img.portrait { object-fit: contain; }
|
||||
.slide-a { opacity: 1; z-index: 1; }
|
||||
.slide-b { opacity: 0; z-index: 0; }
|
||||
|
||||
@@ -174,7 +223,7 @@
|
||||
.ytp-wrap {
|
||||
position: relative; width: 100%; background: #000;
|
||||
border-radius: 12px; overflow: hidden;
|
||||
aspect-ratio: 16/9; max-height: 70vh;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
.ytp {
|
||||
position: relative; width: 100%; height: 100%;
|
||||
@@ -270,6 +319,32 @@
|
||||
.ytp-time-display { font-size: 13px; color: #fff; white-space: nowrap; padding: 0 6px; line-height: 36px; }
|
||||
.ytp-time-sep { opacity: .6; margin: 0 2px; }
|
||||
|
||||
/* ── Standalone language button ──────────────────────────────────── */
|
||||
.ytp-lang-wrap { position: relative; }
|
||||
.ytp-lang-btn { display: flex; align-items: center; justify-content: center; opacity: .85; transition: opacity .2s; }
|
||||
.ytp-lang-btn:hover { opacity: 1; }
|
||||
.ytp-lang-popup {
|
||||
display: none; position: absolute; bottom: 48px; right: 0;
|
||||
background: rgba(28,28,28,.97); border-radius: 12px;
|
||||
min-width: 180px; overflow: hidden;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.7); z-index: 100;
|
||||
}
|
||||
.ytp-lang-popup.open { display: block; }
|
||||
.ytp-lang-popup-hdr {
|
||||
padding: 10px 14px 8px; font-size: 11px; font-weight: 700; letter-spacing: .5px;
|
||||
text-transform: uppercase; color: rgba(255,255,255,.45);
|
||||
border-bottom: 1px solid rgba(255,255,255,.1);
|
||||
}
|
||||
.ytp-lang-option {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; color: #fff; font-size: 13px;
|
||||
cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.ytp-lang-option:hover { background: rgba(255,255,255,.1); }
|
||||
.ytp-lang-opt-label { flex: 1; }
|
||||
.ytp-lang-check { width: 16px; height: 16px; fill: #fff; opacity: 0; flex-shrink: 0; }
|
||||
.ytp-lang-option.active .ytp-lang-check { opacity: 1; }
|
||||
|
||||
.ytp-settings-wrap { position: relative; }
|
||||
.ytp-settings-panel {
|
||||
display: none; position: absolute; bottom: 44px; right: 0;
|
||||
@@ -331,8 +406,13 @@
|
||||
}
|
||||
.ytp-toggle-row.is-on .ytp-toggle-thumb { transform: translateX(12px); }
|
||||
|
||||
/* Standalone loop button */
|
||||
.ytp-loop-btn svg { opacity: .75; transition: opacity .15s, fill .15s; }
|
||||
.ytp-loop-btn:hover svg { opacity: 1; }
|
||||
.ytp-loop-btn.is-on svg { fill: #e61e1e; opacity: 1; }
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.ytp-wrap { border-radius: 0; max-height: 56vw; }
|
||||
.ytp-wrap { border-radius: 0; }
|
||||
.ytp-button { width: 32px; height: 32px; }
|
||||
.ytp-button svg { width: 18px; height: 18px; }
|
||||
.ytp-time-display { font-size: 11px; padding: 0 4px; }
|
||||
@@ -365,8 +445,7 @@ const speedBack = document.getElementById('ytpSpeedBack');
|
||||
const speedLabel = document.getElementById('ytpSpeedLabel');
|
||||
const speedOpts = document.querySelectorAll('.ytp-speed-option');
|
||||
const fsBtn = document.getElementById('ytpFsBtn');
|
||||
const loopRow = document.getElementById('ytpLoopRow');
|
||||
const loopVal = document.getElementById('ytpLoopVal');
|
||||
const loopBtn = document.getElementById('ytpLoopBtn');
|
||||
const barsBtn = document.getElementById('ytpBarsBtn');
|
||||
const animCanvas = document.getElementById('audioAnimCanvas');
|
||||
|
||||
@@ -502,6 +581,150 @@ speedOpts.forEach(opt => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Language track switching ──────────────────────────────────
|
||||
const langBtn = document.getElementById('ytpLangBtn');
|
||||
const langPopup = document.getElementById('ytpLangPopup');
|
||||
const langOpts = document.querySelectorAll('.ytp-lang-option');
|
||||
|
||||
if (langBtn && langPopup) {
|
||||
langBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
// Only open popup when there are multiple language options
|
||||
if (langPopup.querySelectorAll('.ytp-lang-option').length < 2) return;
|
||||
settingsPanel.classList.remove('open');
|
||||
langPopup.classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', () => langPopup.classList.remove('open'));
|
||||
langPopup.addEventListener('click', e => e.stopPropagation());
|
||||
|
||||
langOpts.forEach(opt => {
|
||||
opt.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const url = opt.dataset.langUrl;
|
||||
const flag = opt.dataset.langFlag;
|
||||
if (!url) return;
|
||||
const relPos = audio.duration ? audio.currentTime / audio.duration : 0;
|
||||
const wasPlaying = !audio.paused;
|
||||
const _vol = audio.volume, _muted = audio.muted;
|
||||
audio.src = url; audio.load();
|
||||
audio.volume = _vol; audio.muted = _muted;
|
||||
audio.addEventListener('loadedmetadata', function() {
|
||||
if (audio.duration) audio.currentTime = relPos * audio.duration;
|
||||
updateProgress();
|
||||
if (wasPlaying) audio.play().catch(() => {});
|
||||
}, { once: true });
|
||||
langOpts.forEach(o => o.classList.remove('active'));
|
||||
opt.classList.add('active');
|
||||
langBtn.innerHTML = flag
|
||||
? `<span class="fi fi-${flag}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>`
|
||||
: langBtn.innerHTML;
|
||||
langPopup.classList.remove('open');
|
||||
|
||||
// Update title flag
|
||||
const titleFlagEl = document.getElementById('videoTitleFlag');
|
||||
if (titleFlagEl && flag) {
|
||||
titleFlagEl.className = 'fi fi-' + flag;
|
||||
titleFlagEl.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// Update page title text
|
||||
const titleTextEl = document.getElementById('videoTitleText');
|
||||
const trackTitle = opt.dataset.langTitle || '';
|
||||
const primaryTitle = titleTextEl ? (titleTextEl.dataset.primaryTitle || titleTextEl.textContent) : '';
|
||||
const displayTitle = trackTitle || primaryTitle;
|
||||
if (titleTextEl) titleTextEl.textContent = displayTitle;
|
||||
document.title = displayTitle + ' | {{ config("app.name") }}';
|
||||
|
||||
// Update description box — fall back to primary track description if track has none
|
||||
const primaryOptEl = langPopup.querySelector('.ytp-lang-option[data-lang-id="0"]');
|
||||
const primaryDesc = primaryOptEl ? (primaryOptEl.dataset.langDescription || '') : '';
|
||||
const desc = opt.dataset.langDescription || primaryDesc;
|
||||
_updateDescriptionBox(desc);
|
||||
|
||||
// Update download links
|
||||
const dlUrl = opt.dataset.langDlUrl;
|
||||
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Description box updater ──────────────────────────────────
|
||||
function _updateDescriptionBox(text) {
|
||||
text = (text || '').trim();
|
||||
const SHORT_LEN = 200;
|
||||
const needsExpand = text.length > SHORT_LEN;
|
||||
const short = needsExpand ? text.slice(0, SHORT_LEN) + '…' : text;
|
||||
|
||||
let shortEl = document.getElementById('vdbDescShort');
|
||||
const fullEl = document.getElementById('vdbDescFull');
|
||||
|
||||
// If the description elements don't exist yet, inject them into vdb-about
|
||||
if (!shortEl) {
|
||||
const aboutPanel = document.getElementById('vdb-about');
|
||||
if (aboutPanel) {
|
||||
// Remove existing "No description" placeholder if present
|
||||
const ph = aboutPanel.querySelector('p[style*="text-secondary"]');
|
||||
if (ph) ph.style.display = 'none';
|
||||
|
||||
shortEl = document.createElement('div');
|
||||
shortEl.id = 'vdbDescShort';
|
||||
shortEl.className = 'vdb-desc-text';
|
||||
aboutPanel.appendChild(shortEl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shortEl) return;
|
||||
|
||||
if (!text) {
|
||||
shortEl.style.display = 'none';
|
||||
if (fullEl) fullEl.style.display = 'none';
|
||||
const moreBtn2 = shortEl.nextElementSibling;
|
||||
if (moreBtn2 && moreBtn2.classList.contains('vdb-show-more')) moreBtn2.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Always reset to the collapsed view on a language switch
|
||||
shortEl.textContent = needsExpand ? short : text;
|
||||
shortEl.style.display = '';
|
||||
|
||||
// Ensure the full-text element exists when expansion is needed
|
||||
let full = fullEl;
|
||||
if (needsExpand && !full) {
|
||||
full = document.createElement('div');
|
||||
full.id = 'vdbDescFull';
|
||||
full.className = 'vdb-desc-text';
|
||||
full.style.display = 'none';
|
||||
shortEl.insertAdjacentElement('afterend', full);
|
||||
}
|
||||
if (full) {
|
||||
full.textContent = text;
|
||||
full.style.display = 'none';
|
||||
}
|
||||
|
||||
// Locate (or create) the show-more button — it is NOT necessarily shortEl's
|
||||
// direct sibling because #vdbDescFull sits between them, so search the panel.
|
||||
const aboutPanel = document.getElementById('vdb-about');
|
||||
let moreBtn = aboutPanel ? aboutPanel.querySelector('.vdb-show-more') : null;
|
||||
if (needsExpand) {
|
||||
if (!moreBtn) {
|
||||
moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'vdb-show-more';
|
||||
moreBtn.onclick = function() {
|
||||
const f = document.getElementById('vdbDescFull');
|
||||
const collapsed = !f || f.style.display === 'none';
|
||||
if (f) f.style.display = collapsed ? 'block' : 'none';
|
||||
shortEl.style.display = collapsed ? 'none' : '';
|
||||
moreBtn.textContent = collapsed ? 'Show less' : 'Show more';
|
||||
};
|
||||
(full || shortEl).insertAdjacentElement('afterend', moreBtn);
|
||||
}
|
||||
moreBtn.textContent = 'Show more';
|
||||
moreBtn.style.display = '';
|
||||
} else if (moreBtn) {
|
||||
moreBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fullscreen ───────────────────────────────────────────────
|
||||
fsBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
@@ -558,7 +781,7 @@ audio.addEventListener('seeked', () => {
|
||||
}
|
||||
});
|
||||
audio.addEventListener('timeupdate', updateProgress);
|
||||
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); });
|
||||
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); updateProgress(); });
|
||||
audio.addEventListener('ended', () => { releaseWakeLock(); if (window._plOnTrackEnd) { window._plOnTrackEnd(); } else if (NEXT_URL) { window.location.href = NEXT_URL; } });
|
||||
audio.addEventListener('volumechange', updateVolumeIcon);
|
||||
|
||||
@@ -566,18 +789,19 @@ audio.addEventListener('volumechange', updateVolumeIcon);
|
||||
let isLooping = localStorage.getItem('ytpLoop') === '1';
|
||||
audio.loop = isLooping;
|
||||
function applyLoopState() {
|
||||
if (!loopRow) return;
|
||||
loopRow.classList.toggle('is-on', isLooping);
|
||||
if (loopVal) loopVal.textContent = isLooping ? 'On' : 'Off';
|
||||
if (!loopBtn) return;
|
||||
loopBtn.classList.toggle('is-on', isLooping);
|
||||
loopBtn.title = isLooping ? 'Loop: On' : 'Loop';
|
||||
}
|
||||
applyLoopState();
|
||||
if (loopRow) {
|
||||
loopRow.addEventListener('click', e => {
|
||||
if (loopBtn) {
|
||||
loopBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
isLooping = !isLooping;
|
||||
audio.loop = isLooping;
|
||||
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
|
||||
applyLoopState();
|
||||
showControls();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -828,10 +1052,132 @@ window._audioPlayerUpdate = function(d) {
|
||||
if (coverEl) { coverEl.style.display = ''; coverEl.src = d.cover_url || ''; }
|
||||
}
|
||||
|
||||
// Update page title
|
||||
var titleEl = document.querySelector('.video-title span');
|
||||
if (titleEl) titleEl.textContent = d.title || '';
|
||||
// Rebuild language track popup from player-data response
|
||||
(function() {
|
||||
var langWrap = document.getElementById('ytpLangWrap');
|
||||
var langPopup = document.getElementById('ytpLangPopup');
|
||||
if (!langWrap || !langPopup || !langBtn) return;
|
||||
|
||||
function flagHtml(flag, size) {
|
||||
var s = size === 'sm' ? 'width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;'
|
||||
: 'width:22px;height:16px;border-radius:2px;display:inline-block;';
|
||||
return '<span class="fi fi-' + (flag || 'xx') + '" style="' + s + '"></span>';
|
||||
}
|
||||
|
||||
// Build combined track list: primary + deduped extras
|
||||
var primaryLangNew = d.language || null;
|
||||
var primaryFlagFromApi = d.language_flag || null;
|
||||
var allTracks = [];
|
||||
if (primaryLangNew) {
|
||||
allTracks.push({
|
||||
id: 0,
|
||||
language: primaryLangNew,
|
||||
label: primaryLangNew.toUpperCase(),
|
||||
flag: primaryFlagFromApi,
|
||||
stream_url: d.stream_url,
|
||||
title: d.title || '',
|
||||
description: d.description || '',
|
||||
dl_url: d.download_url || d.stream_url
|
||||
});
|
||||
} else {
|
||||
allTracks.push({ id: 0, language: '', label: 'Default', flag: null, stream_url: d.stream_url, title: d.title || '', description: d.description || '', dl_url: d.download_url || d.stream_url });
|
||||
}
|
||||
(d.audio_tracks || []).forEach(function(t) {
|
||||
allTracks.push({
|
||||
id: t.id,
|
||||
language: t.language,
|
||||
label: t.label,
|
||||
flag: t.flag,
|
||||
stream_url: t.stream_url,
|
||||
title: t.title || '',
|
||||
description: t.description || '',
|
||||
dl_url: t.dl_url || (t.stream_url + '?download=1')
|
||||
});
|
||||
});
|
||||
|
||||
var hasMultiple = allTracks.length > 1;
|
||||
var primaryFlagVal = allTracks[0] ? allTracks[0].flag : null;
|
||||
// Show the button whenever the primary language has a flag; hide only when no flag at all
|
||||
langWrap.style.display = primaryFlagVal ? '' : 'none';
|
||||
langBtn.innerHTML = flagHtml(primaryFlagVal, 'lg');
|
||||
|
||||
if (!hasMultiple) {
|
||||
// Single track — flag is read-only language indicator, no popup needed
|
||||
langPopup.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild popup HTML
|
||||
var html = '<div class="ytp-lang-popup-hdr">Language</div>';
|
||||
allTracks.forEach(function(t, i) {
|
||||
var safeDesc = (t.description || '').replace(/"/g, '"');
|
||||
var safeTitle = (t.title || '').replace(/"/g, '"');
|
||||
html += '<div class="ytp-lang-option' + (i === 0 ? ' active' : '') + '"'
|
||||
+ ' data-lang-id="' + t.id + '"'
|
||||
+ ' data-lang-url="' + t.stream_url + '"'
|
||||
+ ' data-lang-label="' + t.label + '"'
|
||||
+ ' data-lang-flag="' + (t.flag || '') + '"'
|
||||
+ ' data-lang-title="' + safeTitle + '"'
|
||||
+ ' data-lang-description="' + safeDesc + '"'
|
||||
+ ' data-lang-dl-url="' + (t.dl_url || t.stream_url) + '">'
|
||||
+ flagHtml(t.flag, 'sm')
|
||||
+ '<span class="ytp-lang-opt-label">' + t.label + '</span>'
|
||||
+ '<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>'
|
||||
+ '</div>';
|
||||
});
|
||||
langPopup.innerHTML = html;
|
||||
|
||||
// Bind click handlers on rebuilt options
|
||||
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(opt) {
|
||||
opt.addEventListener('click', function() {
|
||||
var url = opt.dataset.langUrl;
|
||||
var flag = opt.dataset.langFlag;
|
||||
var curTime = audio.currentTime;
|
||||
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
|
||||
opt.classList.add('active');
|
||||
langPopup.classList.remove('open');
|
||||
var _vol = audio.volume, _muted = audio.muted;
|
||||
audio.src = url; audio.load();
|
||||
audio.volume = _vol; audio.muted = _muted;
|
||||
audio.addEventListener('loadedmetadata', function() {
|
||||
audio.currentTime = curTime; audio.play().catch(function(){});
|
||||
}, { once: true });
|
||||
langBtn.innerHTML = flagHtml(flag, 'lg');
|
||||
// Update title flag
|
||||
var tfe = document.getElementById('videoTitleFlag');
|
||||
if (tfe && flag) { tfe.className = 'fi fi-' + flag; tfe.style.display = 'inline-block'; }
|
||||
// Update page title text
|
||||
var tte2 = document.getElementById('videoTitleText');
|
||||
var tTitle2 = opt.dataset.langTitle || '';
|
||||
var pTitle2 = tte2 ? (tte2.dataset.primaryTitle || tte2.textContent) : '';
|
||||
var dTitle2 = tTitle2 || pTitle2;
|
||||
if (tte2) tte2.textContent = dTitle2;
|
||||
document.title = dTitle2 + ' | {{ config("app.name") }}';
|
||||
// Update description — fall back to primary track description if track has none
|
||||
var primaryDescFallback = allTracks[0] ? (allTracks[0].description || '') : '';
|
||||
_updateDescriptionBox(opt.dataset.langDescription || primaryDescFallback);
|
||||
// Update download links
|
||||
var dlUrl = opt.dataset.langDlUrl;
|
||||
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(function(l){ l.href = dlUrl; });
|
||||
});
|
||||
});
|
||||
|
||||
// Reset button to primary track flag
|
||||
langBtn.innerHTML = flagHtml(allTracks[0] ? allTracks[0].flag : null, 'lg');
|
||||
}());
|
||||
|
||||
// Update page title + title flag
|
||||
var titleEl = document.getElementById('videoTitleText') || document.querySelector('.video-title span:not(#videoTitleFlag)');
|
||||
if (titleEl) { titleEl.textContent = d.title || ''; titleEl.dataset.primaryTitle = d.title || ''; }
|
||||
document.title = (d.title || '') + ' | {{ config("app.name") }}';
|
||||
var titleFlagEl2 = document.getElementById('videoTitleFlag');
|
||||
if (titleFlagEl2) {
|
||||
var pf = d.language_flag || null;
|
||||
if (pf) { titleFlagEl2.className = 'fi fi-' + pf; titleFlagEl2.style.display = 'inline-block'; }
|
||||
else { titleFlagEl2.style.display = 'none'; }
|
||||
}
|
||||
// Update description to primary track's description
|
||||
_updateDescriptionBox(d.description || '');
|
||||
|
||||
// Reset progress bar
|
||||
if (played) played.style.width = '0%';
|
||||
|
||||
@@ -73,7 +73,11 @@ function switchVdbTab(panelId, btn) {
|
||||
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(panelId).classList.add('active');
|
||||
if (panelId === 'vdb-insights' && !window._insLoaded) loadInsights();
|
||||
if (panelId === 'vdb-insights') {
|
||||
const panel = document.getElementById('vdb-insights');
|
||||
const currentUrl = panel && panel.dataset.insightsBase;
|
||||
if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights();
|
||||
}
|
||||
}
|
||||
function toggleVdbDesc(btn) {
|
||||
const s = document.getElementById('vdbDescShort'), f = document.getElementById('vdbDescFull');
|
||||
|
||||
@@ -747,18 +747,29 @@
|
||||
var recTransiting = false;
|
||||
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') === '1';
|
||||
|
||||
// persists across sidebar swaps so already-autoplayed videos are not repeated
|
||||
window._recWatched = window._recWatched || new Set();
|
||||
|
||||
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
|
||||
|
||||
function recNextUnwatched() {
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recRender() {
|
||||
var btn = document.getElementById('recAutoplayBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('pl-ctrl-active', recAutoplay);
|
||||
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
|
||||
// highlight the next-up card
|
||||
// highlight the first unwatched card as "next up"
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card');
|
||||
cards.forEach(function(c,i){ c.style.opacity = recAutoplay && i===0 ? '1' : ''; });
|
||||
var first = cards[0];
|
||||
if(first) first.classList.toggle('rec-next-up', recAutoplay);
|
||||
var nextUp = recNextUnwatched() || cards[0];
|
||||
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
|
||||
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
|
||||
}
|
||||
|
||||
window.recToggleAutoplay = function() {
|
||||
@@ -846,13 +857,27 @@
|
||||
} catch(e){ console.warn('recSwapContent', e); }
|
||||
}
|
||||
|
||||
window.recGoTo = function(url) { if(url) recTransitionTo(url); };
|
||||
// user manually picks a video — reset watch history so next autoplay starts fresh
|
||||
window.recGoTo = function(url) {
|
||||
if (!url) return;
|
||||
window._recWatched = new Set();
|
||||
recTransitionTo(url);
|
||||
};
|
||||
|
||||
// autoplay: when video ends, play the first rec card
|
||||
// autoplay: mark current video watched, then play first unwatched rec card
|
||||
window._plOnVideoEnd = function() {
|
||||
if (!recAutoplay) return;
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
window._recWatched.add(window.location.href);
|
||||
var next = recNextUnwatched();
|
||||
if (next) {
|
||||
recTransitionTo(next.dataset.recUrl);
|
||||
} else {
|
||||
// all recommendations watched — notify and restart the cycle
|
||||
showToast('All recommendations watched — starting over', 'info');
|
||||
window._recWatched = new Set();
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function(e){ if(e.state&&e.state.recUrl) recTransitionTo(e.state.recUrl,false); });
|
||||
|
||||
@@ -842,9 +842,8 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.fighter-country-flag {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.fighter-country-flag { display: inline-flex; align-items: center; }
|
||||
.fighter-country-flag .fi { width: 22px; height: 16px; border-radius: 2px; }
|
||||
|
||||
.match-center {
|
||||
display: flex;
|
||||
@@ -1893,7 +1892,7 @@
|
||||
$blueFighter ??
|
||||
(object) [
|
||||
'name' => 'Sami Al Manea',
|
||||
'flag' => '🇧🇭',
|
||||
'flag' => 'bh',
|
||||
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_5.png?v=1773157950',
|
||||
'team' => 'Emperor TaeKwonDo Academy',
|
||||
'country' => 'Bahrain',
|
||||
@@ -1905,7 +1904,7 @@
|
||||
$redFighter ??
|
||||
(object) [
|
||||
'name' => 'Hassan Al Shallan',
|
||||
'flag' => '🇧🇭',
|
||||
'flag' => 'bh',
|
||||
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_11.png?t=1773268056907',
|
||||
'team' => 'Legend TaeKwonDo Academy',
|
||||
'country' => 'Bahrain',
|
||||
@@ -1928,7 +1927,7 @@
|
||||
$referee ??
|
||||
(object) [
|
||||
'name' => 'Ghassan Yusuf',
|
||||
'flag' => '🇧🇭',
|
||||
'flag' => 'bh',
|
||||
'photo' => 'https://takeone-dev.innovator.bh/storage/images/profiles/profile_8.png?t=1773267305519',
|
||||
'member_id' => 4,
|
||||
];
|
||||
@@ -1978,7 +1977,7 @@
|
||||
<i class="bi bi-trophy trophy-icon" title="Match Type"></i>
|
||||
<span>{{ $video->title }} –</span>
|
||||
<span style="color: #2563eb; font-weight: bold">
|
||||
{{ $blueFighter->flag ?? '🇧🇭' }}
|
||||
<span class="fi fi-{{ $blueFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>
|
||||
<a href="https://takeone-dev.innovator.bh/member/{{ $blueFighter->member_id ?? 5 }}"
|
||||
title="View {{ $blueFighter->name ?? 'Sami Al Manea' }}'s profile">
|
||||
{{ $blueFighter->name ?? 'Sami Al Manea' }}
|
||||
@@ -1986,7 +1985,7 @@
|
||||
</span>
|
||||
<span>vs</span>
|
||||
<span style="color: #ef4444; font-weight: bold">
|
||||
{{ $redFighter->flag ?? '🇧🇭' }}
|
||||
<span class="fi fi-{{ $redFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span>
|
||||
<a href="https://takeone-dev.innovator.bh/member/{{ $redFighter->member_id ?? 11 }}"
|
||||
title="View {{ $redFighter->name ?? 'Hassan Al Shallan' }}'s profile">
|
||||
{{ $redFighter->name ?? 'Hassan Al Shallan' }}
|
||||
@@ -2009,7 +2008,7 @@
|
||||
<span style="color: var(--text-secondary);">•</span>
|
||||
<span
|
||||
style="color: #ffd900; font-weight: bold; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 2px;">
|
||||
{{ $referee->flag ?? '🇧🇭' }} {{ $referee->name ?? 'Ghassan Yusuf' }}
|
||||
<span class="fi fi-{{ $referee->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span> {{ $referee->name ?? 'Ghassan Yusuf' }}
|
||||
</span>
|
||||
<span
|
||||
style="color: var(--text-secondary); font-weight: bold; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 2px;">
|
||||
@@ -2090,7 +2089,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="fighter-country">
|
||||
<span class="fighter-country-flag">{{ $blueFighter->flag ?? '🇧🇭' }}</span>
|
||||
<span class="fighter-country-flag"><span class="fi fi-{{ $blueFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span></span>
|
||||
<span>{{ $blueFighter->country ?? 'Bahrain' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2165,7 +2164,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="fighter-country">
|
||||
<span class="fighter-country-flag">{{ $redFighter->flag ?? '🇧🇭' }}</span>
|
||||
<span class="fighter-country-flag"><span class="fi fi-{{ $redFighter->flag ?? 'bh' }}" style="width:20px;height:15px;border-radius:2px;display:inline-block;vertical-align:middle;"></span></span>
|
||||
<span>{{ $redFighter->country ?? 'Bahrain' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2821,17 +2820,29 @@
|
||||
var recTransiting = false;
|
||||
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
|
||||
|
||||
// persists across sidebar swaps so already-autoplayed videos are not repeated
|
||||
window._recWatched = window._recWatched || new Set();
|
||||
|
||||
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
|
||||
|
||||
function recNextUnwatched() {
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recRender() {
|
||||
var btn = document.getElementById('recAutoplayBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('pl-ctrl-active', recAutoplay);
|
||||
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
|
||||
// highlight the first unwatched card as "next up"
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card');
|
||||
cards.forEach(function(c,i){ c.style.opacity = recAutoplay && i===0 ? '1' : ''; });
|
||||
var first = cards[0];
|
||||
if(first) first.classList.toggle('rec-next-up', recAutoplay);
|
||||
var nextUp = recNextUnwatched() || cards[0];
|
||||
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
|
||||
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
|
||||
}
|
||||
|
||||
window.recToggleAutoplay = function() {
|
||||
@@ -2907,12 +2918,27 @@
|
||||
} catch(e){ console.warn('recSwapContent', e); }
|
||||
}
|
||||
|
||||
window.recGoTo = function(url) { if(url) recTransitionTo(url); };
|
||||
// user manually picks a video — reset watch history so next autoplay starts fresh
|
||||
window.recGoTo = function(url) {
|
||||
if (!url) return;
|
||||
window._recWatched = new Set();
|
||||
recTransitionTo(url);
|
||||
};
|
||||
|
||||
// autoplay: mark current video watched, then play first unwatched rec card
|
||||
window._plOnVideoEnd = function() {
|
||||
if (!recAutoplay) return;
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
window._recWatched.add(window.location.href);
|
||||
var next = recNextUnwatched();
|
||||
if (next) {
|
||||
recTransitionTo(next.dataset.recUrl);
|
||||
} else {
|
||||
// all recommendations watched — notify and restart the cycle
|
||||
showToast('All recommendations watched — starting over', 'info');
|
||||
window._recWatched = new Set();
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function(e){ if(e.state&&e.state.recUrl) recTransitionTo(e.state.recUrl,false); });
|
||||
|
||||
@@ -451,9 +451,11 @@
|
||||
@endif
|
||||
|
||||
<!-- Video Title with Music Note Icon (Music Type) -->
|
||||
@php $titleLangFlag = \App\Data\Languages::flag($video->language); @endphp
|
||||
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
|
||||
<i class="bi bi-music-note" style="color: #ef4444;"></i>
|
||||
<span>{{ $video->title }}</span>
|
||||
<i class="bi bi-music-note" style="color: #ef4444; flex-shrink:0;"></i>
|
||||
<span class="fi fi-{{ $titleLangFlag ?: 'xx' }}" id="videoTitleFlag" style="width:22px;height:16px;border-radius:2px;display:{{ $titleLangFlag ? 'inline-block' : 'none' }};flex-shrink:0;"></span>
|
||||
<span id="videoTitleText" data-primary-title="{{ $video->title }}">{{ $video->title }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Stats Row - Hidden, shown in description box -->
|
||||
@@ -557,7 +559,11 @@
|
||||
|
||||
// swap audio src (keeps browser autoplay permission)
|
||||
var audio = document.getElementById('audioEl');
|
||||
if (audio) { audio.src=d.stream_url; audio.load(); }
|
||||
if (audio) {
|
||||
var _savedVol = audio.volume; var _savedMuted = audio.muted;
|
||||
audio.src = d.stream_url; audio.load();
|
||||
audio.volume = _savedVol; audio.muted = _savedMuted;
|
||||
}
|
||||
|
||||
// update cover / slideshow / title / progress via shared hook
|
||||
if (window._audioPlayerUpdate) window._audioPlayerUpdate(d);
|
||||
@@ -731,11 +737,27 @@
|
||||
var recTransiting = false;
|
||||
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
|
||||
|
||||
// persists across sidebar swaps so already-autoplayed tracks are not repeated
|
||||
window._recWatched = window._recWatched || new Set();
|
||||
|
||||
function recNextUnwatched() {
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recRender() {
|
||||
var btn = document.getElementById('recAutoplayBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('pl-ctrl-active', recAutoplay);
|
||||
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
|
||||
// highlight the first unwatched card as "next up"
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card');
|
||||
var nextUp = recNextUnwatched() || cards[0];
|
||||
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
|
||||
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
|
||||
}
|
||||
|
||||
async function recTransitionTo(url, pushHist) {
|
||||
@@ -748,7 +770,11 @@
|
||||
var d = await resp.json();
|
||||
|
||||
var audio = document.getElementById('audioEl');
|
||||
if (audio) { audio.src = d.stream_url; audio.load(); }
|
||||
if (audio) {
|
||||
var _savedVol = audio.volume; var _savedMuted = audio.muted;
|
||||
audio.src = d.stream_url; audio.load();
|
||||
audio.volume = _savedVol; audio.muted = _savedMuted;
|
||||
}
|
||||
|
||||
if (window._audioPlayerUpdate) window._audioPlayerUpdate(d);
|
||||
|
||||
@@ -800,17 +826,32 @@
|
||||
} catch(e) { console.warn('recSwapContent', e); }
|
||||
}
|
||||
|
||||
window.recGoTo = function(url) { if (url) recTransitionTo(url); };
|
||||
// user manually picks a track — reset watch history so next autoplay starts fresh
|
||||
window.recGoTo = function(url) {
|
||||
if (!url) return;
|
||||
window._recWatched = new Set();
|
||||
recTransitionTo(url);
|
||||
};
|
||||
window.recToggleAutoplay = function() {
|
||||
recAutoplay = !recAutoplay;
|
||||
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
|
||||
recRender();
|
||||
};
|
||||
|
||||
// autoplay: mark current track watched, then play first unwatched rec card
|
||||
window._plOnTrackEnd = function() {
|
||||
if (!recAutoplay) return;
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
window._recWatched.add(window.location.href);
|
||||
var next = recNextUnwatched();
|
||||
if (next) {
|
||||
recTransitionTo(next.dataset.recUrl);
|
||||
} else {
|
||||
// all recommendations watched — notify and restart the cycle
|
||||
showToast('All recommendations watched — starting over', 'info');
|
||||
window._recWatched = new Set();
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function(e) { if (e.state && e.state.url) recTransitionTo(e.state.url, false); });
|
||||
|
||||
Reference in new issue
Block a user