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
@@ -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>
|
||||
Reference in new issue
Block a user