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

@@ -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);