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

+449 -447
View File
@@ -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>