Add multi-language audio tracks and self-hosted flag-icons

Introduce per-video language support and multiple audio tracks
(VideoAudioTrack model + migrations for language, description, title),
a reusable language-select component, and a track-editor form. Bundle
the self-hosted flag-icons v7.2.3 library and a NAS auto-sync command.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-22 21:32:52 +03:00
1 parent f8d13457fa
commit 66fd78c10f
314 files changed
+16507 -3172

No files matched your search

@@ -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, '&quot;');
var safeTitle = (t.title || '').replace(/"/g, '&quot;');
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%';