tiktok feed mode
This commit is contained in:
@@ -1359,3 +1359,211 @@ body.theme-light .error-toast {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-secondary);
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mode toggle FAB (grid <-> reels) */
|
||||||
|
.mode-toggle-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 90px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 24px var(--shadow);
|
||||||
|
z-index: 2600;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.feed-mode-open .mode-toggle-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.feed-mode-open .mode-toggle-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||||
|
filter: invert(100%) saturate(0%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reels-style scrollable feed */
|
||||||
|
.feed-view {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #000;
|
||||||
|
z-index: 2500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-view.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-scroll {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-slide {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-poster,
|
||||||
|
.feed-video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-poster {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-slide.is-loaded .feed-poster {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-info {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 24px 20px 44px;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-uploader {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 4px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline-track {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: visible;
|
||||||
|
transition: height 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline:hover .feed-timeline-track,
|
||||||
|
.feed-timeline.is-scrubbing .feed-timeline-track {
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline-fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 0%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0%;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
margin-left: -5.5px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%) scale(0);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline:hover .feed-timeline-handle,
|
||||||
|
.feed-timeline.is-scrubbing .feed-timeline-handle {
|
||||||
|
transform: translateY(-50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-mute-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 154px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2600;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-mute-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-mute-btn .icon-svg {
|
||||||
|
filter: invert(100%) saturate(0%) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="mode-toggle-btn" class="mode-toggle-btn" type="button" title="Switch to Reels view" aria-pressed="false">
|
||||||
|
<img class="icon-svg" id="mode-toggle-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg" alt="Switch to Reels view">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="feed-view" class="feed-view" aria-hidden="true">
|
||||||
|
<button id="feed-mute-btn" class="feed-mute-btn" type="button" title="Toggle mute">
|
||||||
|
<img class="icon-svg" id="feed-mute-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg" alt="Unmute">
|
||||||
|
</button>
|
||||||
|
<div id="feed-scroll" class="feed-scroll">
|
||||||
|
<div id="feed-sentinel" class="feed-sentinel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="info-modal" class="info-modal" aria-hidden="true">
|
<div id="info-modal" class="info-modal" aria-hidden="true">
|
||||||
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
|
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
|
||||||
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
||||||
@@ -145,6 +158,7 @@
|
|||||||
<script src="static/js/player.js"></script>
|
<script src="static/js/player.js"></script>
|
||||||
<script src="static/js/favorites.js"></script>
|
<script src="static/js/favorites.js"></script>
|
||||||
<script src="static/js/videos.js"></script>
|
<script src="static/js/videos.js"></script>
|
||||||
|
<script src="static/js/feed.js"></script>
|
||||||
<script src="static/js/ui.js"></script>
|
<script src="static/js/ui.js"></script>
|
||||||
<script src="static/js/main.js"></script>
|
<script src="static/js/main.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||||
|
|||||||
312
frontend/js/feed.js
Normal file
312
frontend/js/feed.js
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
window.App = window.App || {};
|
||||||
|
App.feed = App.feed || {};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const state = App.state;
|
||||||
|
let observer = null;
|
||||||
|
let sentinelObserver = null;
|
||||||
|
|
||||||
|
const getScroller = () => document.getElementById('feed-scroll');
|
||||||
|
|
||||||
|
const destroySlidePlayback = function(slide) {
|
||||||
|
const video = slide.querySelector('.feed-video');
|
||||||
|
slide.classList.remove('is-active');
|
||||||
|
const fill = slide.querySelector('.feed-timeline-fill');
|
||||||
|
if (fill) fill.style.width = '0%';
|
||||||
|
const handle = slide.querySelector('.feed-timeline-handle');
|
||||||
|
if (handle) handle.style.left = '0%';
|
||||||
|
if (!video) return;
|
||||||
|
if (video._hlsPlayer) {
|
||||||
|
video._hlsPlayer.destroy();
|
||||||
|
video._hlsPlayer = null;
|
||||||
|
}
|
||||||
|
video.pause();
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
slide.classList.remove('is-loaded');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTimelinePosition = function(slide, ratio) {
|
||||||
|
const fill = slide.querySelector('.feed-timeline-fill');
|
||||||
|
const handle = slide.querySelector('.feed-timeline-handle');
|
||||||
|
const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`;
|
||||||
|
if (fill) fill.style.width = pct;
|
||||||
|
if (handle) handle.style.left = pct;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekFromPointer = function(slide, video, timeline, clientX) {
|
||||||
|
if (!isFinite(video.duration) || video.duration <= 0) return;
|
||||||
|
const rect = timeline.getBoundingClientRect();
|
||||||
|
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
||||||
|
const clamped = Math.min(1, Math.max(0, ratio));
|
||||||
|
video.currentTime = clamped * video.duration;
|
||||||
|
setTimelinePosition(slide, clamped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindTimeline = function(slide, video) {
|
||||||
|
const timeline = slide.querySelector('.feed-timeline');
|
||||||
|
if (!timeline) return;
|
||||||
|
let scrubbing = false;
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', () => {
|
||||||
|
if (scrubbing || !isFinite(video.duration) || video.duration <= 0) return;
|
||||||
|
setTimelinePosition(slide, video.currentTime / video.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('pointerdown', (event) => {
|
||||||
|
scrubbing = true;
|
||||||
|
timeline.classList.add('is-scrubbing');
|
||||||
|
timeline.setPointerCapture(event.pointerId);
|
||||||
|
seekFromPointer(slide, video, timeline, event.clientX);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.addEventListener('pointermove', (event) => {
|
||||||
|
if (!scrubbing) return;
|
||||||
|
seekFromPointer(slide, video, timeline, event.clientX);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopScrubbing = (event) => {
|
||||||
|
if (!scrubbing) return;
|
||||||
|
scrubbing = false;
|
||||||
|
timeline.classList.remove('is-scrubbing');
|
||||||
|
if (timeline.hasPointerCapture(event.pointerId)) {
|
||||||
|
timeline.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
timeline.addEventListener('pointerup', stopScrubbing);
|
||||||
|
timeline.addEventListener('pointercancel', stopScrubbing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRELOAD_COUNT = 2;
|
||||||
|
|
||||||
|
const loadSlideSource = function(slide, videoData, autoplay) {
|
||||||
|
const video = slide.querySelector('.feed-video');
|
||||||
|
if (!video) return;
|
||||||
|
if (slide.classList.contains('is-loaded')) {
|
||||||
|
if (autoplay) {
|
||||||
|
video.muted = state.feedMuted;
|
||||||
|
const playPromise = video.play();
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slide.classList.add('is-loaded');
|
||||||
|
|
||||||
|
const resolved = App.videos.resolveStreamSource(videoData);
|
||||||
|
if (!resolved.url) return;
|
||||||
|
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
||||||
|
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`;
|
||||||
|
const isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
||||||
|
const canUseHls = !!(window.Hls && window.Hls.isSupported());
|
||||||
|
|
||||||
|
video.muted = state.feedMuted;
|
||||||
|
video.preload = 'auto';
|
||||||
|
|
||||||
|
if (isHls && canUseHls) {
|
||||||
|
const hls = new window.Hls();
|
||||||
|
video._hlsPlayer = hls;
|
||||||
|
hls.loadSource(streamUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(window.Hls.Events.ERROR, (event, data) => {
|
||||||
|
if (data && data.fatal && video._hlsPlayer === hls) {
|
||||||
|
hls.destroy();
|
||||||
|
video._hlsPlayer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
video.src = streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
const playPromise = video.play();
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activateSlide = function(slide, videoData) {
|
||||||
|
loadSlideSource(slide, videoData, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keeps exactly [active, active+1, active+2] loaded: preloads the next
|
||||||
|
// PRELOAD_COUNT slides and tears down everything else. Centralizing this
|
||||||
|
// here (instead of letting each slide's own enter/leave intersection
|
||||||
|
// entry decide) avoids a race where the initial batch of "not
|
||||||
|
// intersecting" entries for not-yet-visible slides undoes the preload
|
||||||
|
// we just triggered for them.
|
||||||
|
const syncWindow = function(activeSlide) {
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (!scroller) return;
|
||||||
|
const slides = Array.from(scroller.querySelectorAll('.feed-slide'));
|
||||||
|
const index = slides.indexOf(activeSlide);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const keep = new Set();
|
||||||
|
for (let i = index; i <= index + PRELOAD_COUNT && i < slides.length; i++) {
|
||||||
|
keep.add(slides[i]);
|
||||||
|
}
|
||||||
|
slides.forEach((slide) => {
|
||||||
|
if (keep.has(slide)) return;
|
||||||
|
if (slide.classList.contains('is-loaded')) {
|
||||||
|
destroySlidePlayback(slide);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (let i = index + 1; i <= index + PRELOAD_COUNT && i < slides.length; i++) {
|
||||||
|
loadSlideSource(slides[i], slides[i]._videoData, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.isOpen = function() {
|
||||||
|
return !!state.feedOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.renderSlides = function() {
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (!scroller) return;
|
||||||
|
(state.loadedVideos || []).forEach((v) => {
|
||||||
|
if (state.feedRenderedIds.has(v.id)) return;
|
||||||
|
state.feedRenderedIds.add(v.id);
|
||||||
|
|
||||||
|
const slide = document.createElement('div');
|
||||||
|
slide.className = 'feed-slide';
|
||||||
|
slide.dataset.videoId = v.id;
|
||||||
|
const uploaderText = v.uploader || '';
|
||||||
|
slide.innerHTML = `
|
||||||
|
<img class="feed-poster" src="${v.thumb || ''}" alt="">
|
||||||
|
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
|
||||||
|
<div class="feed-info">
|
||||||
|
<h4 class="feed-title">${v.title || ''}</h4>
|
||||||
|
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="feed-timeline" role="slider" aria-label="Seek">
|
||||||
|
<div class="feed-timeline-track">
|
||||||
|
<div class="feed-timeline-fill"></div>
|
||||||
|
<div class="feed-timeline-handle"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const poster = slide.querySelector('.feed-poster');
|
||||||
|
App.videos.attachNoReferrerRetry(poster);
|
||||||
|
slide._videoData = v;
|
||||||
|
bindTimeline(slide, slide.querySelector('.feed-video'));
|
||||||
|
scroller.insertBefore(slide, document.getElementById('feed-sentinel'));
|
||||||
|
if (observer) observer.observe(slide);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.reset = function() {
|
||||||
|
const scroller = getScroller();
|
||||||
|
document.querySelectorAll('.feed-slide').forEach((slide) => {
|
||||||
|
if (observer) observer.unobserve(slide);
|
||||||
|
destroySlidePlayback(slide);
|
||||||
|
slide.remove();
|
||||||
|
});
|
||||||
|
state.feedRenderedIds.clear();
|
||||||
|
state.feedActiveSlide = null;
|
||||||
|
if (scroller) scroller.scrollTop = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.open = function() {
|
||||||
|
const container = document.getElementById('feed-view');
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (!container || !scroller) return;
|
||||||
|
state.feedOpen = true;
|
||||||
|
|
||||||
|
if (App.player && typeof App.player.close === 'function') {
|
||||||
|
App.player.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!observer) {
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const slide = entry.target;
|
||||||
|
if (entry.isIntersecting && entry.intersectionRatio >= 0.6 && state.feedActiveSlide !== slide) {
|
||||||
|
const previousActive = state.feedActiveSlide;
|
||||||
|
state.feedActiveSlide = slide;
|
||||||
|
if (previousActive) previousActive.classList.remove('is-active');
|
||||||
|
slide.classList.add('is-active');
|
||||||
|
activateSlide(slide, slide._videoData);
|
||||||
|
syncWindow(slide);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: [0, 0.6, 1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sentinelObserver) {
|
||||||
|
sentinelObserver = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
App.videos.loadVideos();
|
||||||
|
}
|
||||||
|
}, { threshold: 0.01 });
|
||||||
|
const sentinel = document.getElementById('feed-sentinel');
|
||||||
|
if (sentinel) sentinelObserver.observe(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
App.feed.renderSlides();
|
||||||
|
document.querySelectorAll('.feed-slide').forEach((slide) => observer.observe(slide));
|
||||||
|
|
||||||
|
container.classList.add('open');
|
||||||
|
container.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.classList.add('feed-mode-open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
App.feed.updateToggleButton();
|
||||||
|
App.feed.updateMuteButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.close = function() {
|
||||||
|
const container = document.getElementById('feed-view');
|
||||||
|
if (!container) return;
|
||||||
|
state.feedOpen = false;
|
||||||
|
document.querySelectorAll('.feed-slide').forEach((slide) => destroySlidePlayback(slide));
|
||||||
|
state.feedActiveSlide = null;
|
||||||
|
container.classList.remove('open');
|
||||||
|
container.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.classList.remove('feed-mode-open');
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
App.feed.updateToggleButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.toggle = function() {
|
||||||
|
if (state.feedOpen) {
|
||||||
|
App.feed.close();
|
||||||
|
} else {
|
||||||
|
App.feed.open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.toggleMute = function() {
|
||||||
|
state.feedMuted = !state.feedMuted;
|
||||||
|
document.querySelectorAll('.feed-video').forEach((video) => {
|
||||||
|
video.muted = state.feedMuted;
|
||||||
|
});
|
||||||
|
App.feed.updateMuteButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.updateToggleButton = function() {
|
||||||
|
const btn = document.getElementById('mode-toggle-btn');
|
||||||
|
const icon = document.getElementById('mode-toggle-icon');
|
||||||
|
if (!btn || !icon) return;
|
||||||
|
const open = !!state.feedOpen;
|
||||||
|
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||||
|
const label = open ? 'Back to grid' : 'Switch to Reels view';
|
||||||
|
btn.title = label;
|
||||||
|
icon.alt = label;
|
||||||
|
icon.src = open
|
||||||
|
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/squares-2x2.svg'
|
||||||
|
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg';
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.updateMuteButton = function() {
|
||||||
|
const icon = document.getElementById('feed-mute-icon');
|
||||||
|
if (!icon) return;
|
||||||
|
icon.src = state.feedMuted
|
||||||
|
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
|
||||||
|
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
|
||||||
|
icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -13,7 +13,12 @@ App.state = {
|
|||||||
playerMode: 'modal',
|
playerMode: 'modal',
|
||||||
playerHome: null,
|
playerHome: null,
|
||||||
onFullscreenChange: null,
|
onFullscreenChange: null,
|
||||||
onWebkitEndFullscreen: null
|
onWebkitEndFullscreen: null,
|
||||||
|
loadedVideos: [],
|
||||||
|
feedOpen: false,
|
||||||
|
feedMuted: true,
|
||||||
|
feedRenderedIds: new Set(),
|
||||||
|
feedActiveSlide: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local storage keys used across modules.
|
// Local storage keys used across modules.
|
||||||
|
|||||||
@@ -501,6 +501,20 @@ App.ui = App.ui || {};
|
|||||||
window.closePlayer = App.player.close;
|
window.closePlayer = App.player.close;
|
||||||
window.handleSearch = App.videos.handleSearch;
|
window.handleSearch = App.videos.handleSearch;
|
||||||
|
|
||||||
|
const modeToggleBtn = document.getElementById('mode-toggle-btn');
|
||||||
|
if (modeToggleBtn) {
|
||||||
|
modeToggleBtn.onclick = () => {
|
||||||
|
App.feed.toggle();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedMuteBtn = document.getElementById('feed-mute-btn');
|
||||||
|
if (feedMuteBtn) {
|
||||||
|
feedMuteBtn.onclick = () => {
|
||||||
|
App.feed.toggleMute();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const clearSearchBtn = document.getElementById('search-clear-btn');
|
const clearSearchBtn = document.getElementById('search-clear-btn');
|
||||||
if (searchInput && clearSearchBtn) {
|
if (searchInput && clearSearchBtn) {
|
||||||
@@ -528,6 +542,9 @@ App.ui = App.ui || {};
|
|||||||
App.ui.closeDrawers();
|
App.ui.closeDrawers();
|
||||||
App.ui.closeInfo();
|
App.ui.closeInfo();
|
||||||
App.videos.closeAllMenus();
|
App.videos.closeAllMenus();
|
||||||
|
if (App.feed.isOpen()) {
|
||||||
|
App.feed.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ App.videos = App.videos || {};
|
|||||||
const favoritesSet = App.favorites.getSet();
|
const favoritesSet = App.favorites.getSet();
|
||||||
items.forEach(v => {
|
items.forEach(v => {
|
||||||
if (state.renderedVideoIds.has(v.id)) return;
|
if (state.renderedVideoIds.has(v.id)) return;
|
||||||
|
state.loadedVideos.push(v);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'video-card';
|
card.className = 'video-card';
|
||||||
@@ -317,6 +318,9 @@ App.videos = App.videos || {};
|
|||||||
});
|
});
|
||||||
|
|
||||||
App.videos.scheduleMasonryLayout();
|
App.videos.scheduleMasonryLayout();
|
||||||
|
if (App.feed && typeof App.feed.renderSlides === 'function') {
|
||||||
|
App.feed.renderSlides();
|
||||||
|
}
|
||||||
App.videos.ensureViewportFilled();
|
App.videos.ensureViewportFilled();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,8 +337,12 @@ App.videos = App.videos || {};
|
|||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
state.hasNextPage = true;
|
state.hasNextPage = true;
|
||||||
state.renderedVideoIds.clear();
|
state.renderedVideoIds.clear();
|
||||||
|
state.loadedVideos = [];
|
||||||
const grid = document.getElementById('video-grid');
|
const grid = document.getElementById('video-grid');
|
||||||
if (grid) grid.innerHTML = "";
|
if (grid) grid.innerHTML = "";
|
||||||
|
if (App.feed && typeof App.feed.reset === 'function') {
|
||||||
|
App.feed.reset();
|
||||||
|
}
|
||||||
App.videos.updateLoadMoreState();
|
App.videos.updateLoadMoreState();
|
||||||
App.videos.loadVideos();
|
App.videos.loadVideos();
|
||||||
};
|
};
|
||||||
@@ -348,8 +356,12 @@ App.videos = App.videos || {};
|
|||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
state.hasNextPage = true;
|
state.hasNextPage = true;
|
||||||
state.renderedVideoIds.clear();
|
state.renderedVideoIds.clear();
|
||||||
|
state.loadedVideos = [];
|
||||||
const grid = document.getElementById('video-grid');
|
const grid = document.getElementById('video-grid');
|
||||||
if (grid) grid.innerHTML = "";
|
if (grid) grid.innerHTML = "";
|
||||||
|
if (App.feed && typeof App.feed.reset === 'function') {
|
||||||
|
App.feed.reset();
|
||||||
|
}
|
||||||
App.videos.updateLoadMoreState();
|
App.videos.updateLoadMoreState();
|
||||||
App.videos.loadVideos();
|
App.videos.loadVideos();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user