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
@@ -14,7 +14,7 @@
|
||||
default => '',
|
||||
};
|
||||
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
|
||||
$mp4Url = route('videos.stream', $video);
|
||||
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
|
||||
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
|
||||
$prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
||||
|
||||
@@ -154,13 +154,6 @@
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
{{-- Loop toggle --}}
|
||||
<div class="ytp-settings-item ytp-toggle-row" id="ytpLoopRow">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||||
<span>Loop</span>
|
||||
<span class="ytp-settings-val ytp-toggle-val" id="ytpLoopVal">Off</span>
|
||||
<div class="ytp-toggle-switch" id="ytpLoopSwitch"><div class="ytp-toggle-thumb"></div></div>
|
||||
</div>
|
||||
{{-- Autoplay toggle (playlist only) --}}
|
||||
@if($playlist)
|
||||
<div class="ytp-settings-item ytp-toggle-row" id="ytpAutoplayRow">
|
||||
@@ -182,6 +175,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loop — outside gear, immediately to its right --}}
|
||||
<button class="ytp-button ytp-loop-btn" id="ytpLoopBtn" title="Loop">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||||
</button>
|
||||
|
||||
{{-- Picture-in-Picture --}}
|
||||
<button class="ytp-button ytp-pip-btn" id="ytpPipBtn" title="Miniplayer (i)">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"/></svg>
|
||||
@@ -222,9 +220,9 @@
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 70vh;
|
||||
}
|
||||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-width: 50vh; margin: 0 auto; max-height: unset; }
|
||||
.ytp-wrap.square { aspect-ratio: 1/1; max-width: 70vh; margin: 0 auto; max-height: unset; }
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: unset; }
|
||||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
|
||||
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
|
||||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
|
||||
|
||||
/* Theater mode */
|
||||
.ytp-wrap.theater {
|
||||
@@ -586,7 +584,9 @@
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; }
|
||||
.ytp-wrap.portrait { max-height: unset; }
|
||||
.ytp-wrap.portrait { max-height: 75vh; width: auto; max-width: 100%; }
|
||||
.ytp-wrap.square { max-height: 85vw; max-width: 85vw; }
|
||||
.ytp-wrap.ultrawide { max-height: 50vw; }
|
||||
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
|
||||
@@ -600,6 +600,11 @@
|
||||
/* Hide PIP if not supported – handled by JS */
|
||||
.ytp-pip-btn.unsupported { display: none; }
|
||||
|
||||
/* Standalone loop button */
|
||||
.ytp-loop-btn svg { opacity: .75; transition: opacity .15s, fill .15s; }
|
||||
.ytp-loop-btn:hover svg { opacity: 1; }
|
||||
.ytp-loop-btn.is-on svg { fill: #e61e1e; opacity: 1; }
|
||||
|
||||
/* Settings panel toggle rows */
|
||||
.ytp-toggle-row { border-top: 1px solid rgba(255,255,255,.08); }
|
||||
.ytp-toggle-row svg { opacity: .75; }
|
||||
@@ -705,8 +710,7 @@ const speedOpts = document.querySelectorAll('.ytp-speed-option');
|
||||
const fsBtn = document.getElementById('ytpFsBtn');
|
||||
const pipBtn = document.getElementById('ytpPipBtn');
|
||||
const theaterBtn = document.getElementById('ytpTheaterBtn');
|
||||
const loopRow = document.getElementById('ytpLoopRow');
|
||||
const loopVal = document.getElementById('ytpLoopVal');
|
||||
const loopBtn = document.getElementById('ytpLoopBtn');
|
||||
|
||||
// ── State ─────────────────────────────────────────────
|
||||
let hideTimer = null;
|
||||
@@ -806,7 +810,12 @@ function getShuffledPrevUrl() {
|
||||
return vids[order[posPrev]]?.showUrl ?? PREV_URL;
|
||||
}
|
||||
|
||||
window._ytpHls = null;
|
||||
window._ytpHls = null;
|
||||
window._ytpMasterHls = @json($hlsUrl);
|
||||
window._ytpMasterMp4 = @json($mp4Url);
|
||||
window._ytpWasPlaying = false;
|
||||
video.addEventListener('play', function () { window._ytpWasPlaying = true; });
|
||||
video.addEventListener('pause', function () { window._ytpWasPlaying = false; });
|
||||
|
||||
function initSource() {
|
||||
video.muted = true;
|
||||
@@ -841,13 +850,18 @@ function initSource() {
|
||||
|
||||
// Reinitialize source after SPA transition — called by playlist overlay scripts
|
||||
window._ytpLoadSource = function(hlsUrl, mp4Url) {
|
||||
window._ytpMasterHls = hlsUrl || null;
|
||||
window._ytpMasterMp4 = mp4Url || null;
|
||||
const _vol = video.volume;
|
||||
const _muted = video.muted;
|
||||
if (window._ytpHls) { window._ytpHls.destroy(); window._ytpHls = null; }
|
||||
video.muted = true;
|
||||
video.autoplay = true;
|
||||
if (hlsUrl && window.Hls && Hls.isSupported()) {
|
||||
window._ytpHls = new Hls({ startLevel: -1 });
|
||||
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, function() {
|
||||
video.muted = false;
|
||||
video.volume = _vol;
|
||||
video.muted = _muted;
|
||||
video.play().catch(function(){});
|
||||
});
|
||||
window._ytpHls.loadSource(hlsUrl);
|
||||
@@ -855,15 +869,17 @@ window._ytpLoadSource = function(hlsUrl, mp4Url) {
|
||||
} else if (hlsUrl && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = hlsUrl;
|
||||
video.load();
|
||||
video.volume = _vol;
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.muted = false;
|
||||
video.muted = _muted;
|
||||
video.play().catch(function(){});
|
||||
}, { once: true });
|
||||
} else {
|
||||
video.src = mp4Url;
|
||||
video.load();
|
||||
video.volume = _vol;
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.muted = false;
|
||||
video.muted = _muted;
|
||||
video.play().catch(function(){});
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -1156,18 +1172,19 @@ if (theaterBtn) {
|
||||
let isLooping = localStorage.getItem('ytpLoop') === '1';
|
||||
video.loop = isLooping;
|
||||
function applyLoopState() {
|
||||
if (!loopRow) return;
|
||||
loopRow.classList.toggle('is-on', isLooping);
|
||||
if (loopVal) loopVal.textContent = isLooping ? 'On' : 'Off';
|
||||
if (!loopBtn) return;
|
||||
loopBtn.classList.toggle('is-on', isLooping);
|
||||
loopBtn.title = isLooping ? 'Loop: On' : 'Loop';
|
||||
}
|
||||
applyLoopState();
|
||||
if (loopRow) {
|
||||
loopRow.addEventListener('click', e => {
|
||||
if (loopBtn) {
|
||||
loopBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
isLooping = !isLooping;
|
||||
video.loop = isLooping;
|
||||
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
|
||||
applyLoopState();
|
||||
showControls();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1354,6 +1371,30 @@ function init() {
|
||||
// Initial state
|
||||
largePlay.classList.add('visible');
|
||||
showControls();
|
||||
|
||||
// Scroll-based mini player: watch when #ytpWrap leaves the viewport
|
||||
if (window.IntersectionObserver && window._miniPlayer) {
|
||||
/* On mobile, #main is the scroll container; on desktop the window scrolls */
|
||||
var _scrollRoot = window.innerWidth <= 768 ? document.getElementById('main') : null;
|
||||
var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */
|
||||
new IntersectionObserver(function (entries) {
|
||||
var e0 = entries[0];
|
||||
/* Only activate after the video has actually started playing (_ytpWasPlaying).
|
||||
Using !video.paused was unreliable: autoplay fires asynchronously and the
|
||||
initial IntersectionObserver callback could run before HLS.js even attaches,
|
||||
teleporting the element before it ever played in the main player. */
|
||||
if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && !window._miniPlayer.isNavMode()) {
|
||||
_scrollMiniOn = true;
|
||||
window._miniPlayer.activateScroll(
|
||||
document.title.replace(/\s*\|.*$/, '').trim(),
|
||||
window.location.href
|
||||
);
|
||||
} else if (e0.isIntersecting && _scrollMiniOn) {
|
||||
_scrollMiniOn = false;
|
||||
window._miniPlayer.deactivateScroll();
|
||||
}
|
||||
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
Reference in new issue
Block a user