dynamic video loading
This commit is contained in:
@@ -13,23 +13,39 @@ POLL=60 # how often to check on the app
|
|||||||
|
|
||||||
# Upgrade yt-dlp only if PyPI has a newer version.
|
# Upgrade yt-dlp only if PyPI has a newer version.
|
||||||
# Returns 0 if an update was installed, 1 otherwise (incl. errors / up to date).
|
# Returns 0 if an update was installed, 1 otherwise (incl. errors / up to date).
|
||||||
|
# Versions are compared numerically: the installed version can be zero-padded
|
||||||
|
# (e.g. 2026.06.09) while PyPI reports the normalized form (e.g. 2026.6.9).
|
||||||
check_and_update() {
|
check_and_update() {
|
||||||
current=$(python -c "import yt_dlp; print(yt_dlp.version.__version__)" 2>/dev/null) || return 1
|
if python3 - <<'PY'
|
||||||
latest=$(python -c "import json,urllib.request; print(json.load(urllib.request.urlopen('https://pypi.org/pypi/yt-dlp/json', timeout=30))['info']['version'])" 2>/dev/null) || {
|
import json, sys, urllib.request
|
||||||
echo "Could not reach PyPI to check for yt-dlp updates; will retry."
|
try:
|
||||||
return 1
|
import yt_dlp
|
||||||
}
|
current = yt_dlp.version.__version__
|
||||||
|
latest = json.load(urllib.request.urlopen(
|
||||||
|
'https://pypi.org/pypi/yt-dlp/json', timeout=30))['info']['version']
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not check for yt-dlp updates ({e}); will retry.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if [ "$current" = "$latest" ]; then
|
def key(v):
|
||||||
echo "yt-dlp is up to date ($current)."
|
try:
|
||||||
return 1
|
return tuple(int(p) for p in v.split('.'))
|
||||||
fi
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
echo "New yt-dlp available: $current -> $latest. Updating..."
|
ck, lk = key(current), key(latest)
|
||||||
if pip install --upgrade --quiet --root-user-action=ignore "yt-dlp[default,curl-cffi]"; then
|
newer = (lk > ck) if (ck and lk) else (current != latest)
|
||||||
return 0
|
if newer:
|
||||||
|
print(f"New yt-dlp available: {current} -> {latest}. Updating...")
|
||||||
|
sys.exit(0)
|
||||||
|
print(f"yt-dlp is up to date ({current}).")
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
pip install --upgrade --quiet --root-user-action=ignore "yt-dlp[default,curl-cffi]" \
|
||||||
|
&& return 0
|
||||||
|
echo "yt-dlp update failed; continuing with the installed version."
|
||||||
fi
|
fi
|
||||||
echo "yt-dlp update failed; continuing with the installed version."
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1431,6 +1431,14 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stands in for the slides above the rendered window so scroll position and
|
||||||
|
snap points stay stable while slides are virtualized in and out. */
|
||||||
|
.feed-top-spacer {
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.feed-slide {
|
.feed-slide {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -135,6 +135,7 @@
|
|||||||
<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">
|
<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>
|
</button>
|
||||||
<div id="feed-scroll" class="feed-scroll">
|
<div id="feed-scroll" class="feed-scroll">
|
||||||
|
<div id="feed-top-spacer" class="feed-top-spacer"></div>
|
||||||
<div id="feed-sentinel" class="feed-sentinel"></div>
|
<div id="feed-sentinel" class="feed-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,49 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
const state = App.state;
|
const state = App.state;
|
||||||
let observer = null;
|
|
||||||
let sentinelObserver = null;
|
// Tuning knobs for the virtualized feed.
|
||||||
|
//
|
||||||
|
// The feed is a y-scroll-snap list where every slide is exactly one
|
||||||
|
// viewport tall. We never keep the whole list in the DOM. Instead we keep
|
||||||
|
// a sliding window of slides around the active one:
|
||||||
|
//
|
||||||
|
// [active - HISTORY_COUNT .. active + RENDER_AHEAD]
|
||||||
|
//
|
||||||
|
// Everything outside that range is removed from the document; its video
|
||||||
|
// JSON stays in state.loadedVideos so the slide is rebuilt instantly when
|
||||||
|
// the user scrolls back to (or forward into) it. A top spacer absorbs the
|
||||||
|
// height of the not-yet-rendered slides above the window, so adding or
|
||||||
|
// removing slides never shifts the scroll position: any slide at index i
|
||||||
|
// always sits at scrollTop === i * slideHeight regardless of the window.
|
||||||
|
//
|
||||||
|
// Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the
|
||||||
|
// active slide so the data buffer is always full before we need to render
|
||||||
|
// a slide from it.
|
||||||
|
const PRELOAD_COUNT = 2; // slides ahead kept with a live <video> playing/preloaded
|
||||||
|
const RENDER_AHEAD = 5; // slides ahead kept materialized in the DOM
|
||||||
|
const HISTORY_COUNT = 5; // slides behind kept materialized in the DOM
|
||||||
|
const PREFETCH_PAGES = 2; // pages of JSON to keep buffered ahead of the active slide
|
||||||
|
|
||||||
|
// Map of loadedVideos index -> rendered .feed-slide element.
|
||||||
|
const slidesByIndex = new Map();
|
||||||
|
let scrollBound = false;
|
||||||
|
let scrollRaf = null;
|
||||||
|
|
||||||
const getScroller = () => document.getElementById('feed-scroll');
|
const getScroller = () => document.getElementById('feed-scroll');
|
||||||
|
const getSentinel = () => document.getElementById('feed-sentinel');
|
||||||
|
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
||||||
|
|
||||||
|
const slideHeight = function() {
|
||||||
|
const scroller = getScroller();
|
||||||
|
return (scroller && scroller.clientHeight) || window.innerHeight || 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampIndex = function(index) {
|
||||||
|
const total = (state.loadedVideos || []).length;
|
||||||
|
if (total === 0) return -1;
|
||||||
|
return Math.min(total - 1, Math.max(0, index));
|
||||||
|
};
|
||||||
|
|
||||||
const destroySlidePlayback = function(slide) {
|
const destroySlidePlayback = function(slide) {
|
||||||
const video = slide.querySelector('.feed-video');
|
const video = slide.querySelector('.feed-video');
|
||||||
@@ -82,8 +121,6 @@ App.feed = App.feed || {};
|
|||||||
timeline.addEventListener('pointercancel', stopScrubbing);
|
timeline.addEventListener('pointercancel', stopScrubbing);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRELOAD_COUNT = 2;
|
|
||||||
|
|
||||||
const loadSlideSource = function(slide, videoData, autoplay) {
|
const loadSlideSource = function(slide, videoData, autoplay) {
|
||||||
const video = slide.querySelector('.feed-video');
|
const video = slide.querySelector('.feed-video');
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
@@ -129,54 +166,24 @@ App.feed = App.feed || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activateSlide = function(slide, videoData) {
|
// Builds (or returns) the .feed-slide element for loadedVideos[index] and
|
||||||
loadSlideSource(slide, videoData, true);
|
// inserts it into the DOM in index order, between the top spacer and the
|
||||||
};
|
// sentinel.
|
||||||
|
const createSlide = function(index) {
|
||||||
// Keeps exactly [active, active+1, active+2] loaded: preloads the next
|
if (slidesByIndex.has(index)) return slidesByIndex.get(index);
|
||||||
// PRELOAD_COUNT slides and tears down everything else. Centralizing this
|
const v = (state.loadedVideos || [])[index];
|
||||||
// here (instead of letting each slide's own enter/leave intersection
|
if (!v) return null;
|
||||||
// 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();
|
const scroller = getScroller();
|
||||||
if (!scroller) return;
|
if (!scroller) return null;
|
||||||
const slides = Array.from(scroller.querySelectorAll('.feed-slide'));
|
|
||||||
const index = slides.indexOf(activeSlide);
|
|
||||||
if (index === -1) return;
|
|
||||||
|
|
||||||
const keep = new Set();
|
const slide = document.createElement('div');
|
||||||
for (let i = index; i <= index + PRELOAD_COUNT && i < slides.length; i++) {
|
slide.className = 'feed-slide';
|
||||||
keep.add(slides[i]);
|
slide.dataset.videoId = v.id;
|
||||||
}
|
slide.dataset.index = String(index);
|
||||||
slides.forEach((slide) => {
|
slide._videoData = v;
|
||||||
if (keep.has(slide)) return;
|
slide._index = index;
|
||||||
if (slide.classList.contains('is-loaded')) {
|
const uploaderText = v.uploader || '';
|
||||||
destroySlidePlayback(slide);
|
slide.innerHTML = `
|
||||||
}
|
|
||||||
});
|
|
||||||
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="">
|
<img class="feed-poster" src="${v.thumb || ''}" alt="">
|
||||||
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
|
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
|
||||||
<div class="feed-info">
|
<div class="feed-info">
|
||||||
@@ -190,24 +197,140 @@ App.feed = App.feed || {};
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const poster = slide.querySelector('.feed-poster');
|
const poster = slide.querySelector('.feed-poster');
|
||||||
App.videos.attachNoReferrerRetry(poster);
|
App.videos.attachNoReferrerRetry(poster);
|
||||||
slide._videoData = v;
|
bindTimeline(slide, slide.querySelector('.feed-video'));
|
||||||
bindTimeline(slide, slide.querySelector('.feed-video'));
|
|
||||||
scroller.insertBefore(slide, document.getElementById('feed-sentinel'));
|
// Insert before the rendered slide with the next-highest index so DOM
|
||||||
if (observer) observer.observe(slide);
|
// order always matches index order; fall back to the sentinel.
|
||||||
|
let ref = getSentinel();
|
||||||
|
let refIndex = Infinity;
|
||||||
|
slidesByIndex.forEach((el, i) => {
|
||||||
|
if (i > index && i < refIndex) {
|
||||||
|
refIndex = i;
|
||||||
|
ref = el;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scroller.insertBefore(slide, ref);
|
||||||
|
slidesByIndex.set(index, slide);
|
||||||
|
return slide;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlide = function(index) {
|
||||||
|
const slide = slidesByIndex.get(index);
|
||||||
|
if (!slide) return;
|
||||||
|
destroySlidePlayback(slide);
|
||||||
|
slide.remove();
|
||||||
|
slidesByIndex.delete(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Brings the rendered window in line with the active index: drops slides
|
||||||
|
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
||||||
|
// any missing ones inside it, and sizes the top spacer to stand in for the
|
||||||
|
// slides above the window.
|
||||||
|
const syncWindow = function(activeIndex) {
|
||||||
|
const total = (state.loadedVideos || []).length;
|
||||||
|
if (total === 0) return;
|
||||||
|
const start = Math.max(0, activeIndex - HISTORY_COUNT);
|
||||||
|
const end = Math.min(total - 1, activeIndex + RENDER_AHEAD);
|
||||||
|
|
||||||
|
slidesByIndex.forEach((slide, i) => {
|
||||||
|
if (i < start || i > end) removeSlide(i);
|
||||||
|
});
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
if (!slidesByIndex.has(i)) createSlide(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spacer = getTopSpacer();
|
||||||
|
if (spacer) spacer.style.height = `${start * slideHeight()}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Once the active slide gets within PREFETCH_PAGES of the end of the loaded
|
||||||
|
// JSON, pull the next page so the buffer stays ahead of the rendered window.
|
||||||
|
const prefetchIfNeeded = function(activeIndex) {
|
||||||
|
const total = (state.loadedVideos || []).length;
|
||||||
|
const bufferAhead = total - 1 - activeIndex;
|
||||||
|
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
|
||||||
|
&& state.hasNextPage && !state.isLoading) {
|
||||||
|
App.videos.loadVideos();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Promotes the slide at `index` to active: syncs the window, updates the
|
||||||
|
// active styling, plays it, preloads the next PRELOAD_COUNT, and tears down
|
||||||
|
// playback for everything else in the window.
|
||||||
|
const setActive = function(index) {
|
||||||
|
const clamped = clampIndex(index);
|
||||||
|
if (clamped < 0) return;
|
||||||
|
state.feedActiveIndex = clamped;
|
||||||
|
|
||||||
|
syncWindow(clamped);
|
||||||
|
|
||||||
|
slidesByIndex.forEach((slide, i) => {
|
||||||
|
slide.classList.toggle('is-active', i === clamped);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSlide = slidesByIndex.get(clamped);
|
||||||
|
if (activeSlide) loadSlideSource(activeSlide, activeSlide._videoData, true);
|
||||||
|
|
||||||
|
slidesByIndex.forEach((slide, i) => {
|
||||||
|
if (i === clamped) return;
|
||||||
|
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
||||||
|
loadSlideSource(slide, slide._videoData, false);
|
||||||
|
} else if (slide.classList.contains('is-loaded')) {
|
||||||
|
destroySlidePlayback(slide);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prefetchIfNeeded(clamped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = function() {
|
||||||
|
if (scrollRaf) return;
|
||||||
|
scrollRaf = requestAnimationFrame(() => {
|
||||||
|
scrollRaf = null;
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (!scroller) return;
|
||||||
|
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
||||||
|
if (index < 0) return;
|
||||||
|
if (index !== state.feedActiveIndex) {
|
||||||
|
setActive(index);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
App.feed.reset = function() {
|
const onResize = function() {
|
||||||
|
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
||||||
|
const h = slideHeight();
|
||||||
|
const start = Math.max(0, state.feedActiveIndex - HISTORY_COUNT);
|
||||||
|
const spacer = getTopSpacer();
|
||||||
|
if (spacer) spacer.style.height = `${start * h}px`;
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
document.querySelectorAll('.feed-slide').forEach((slide) => {
|
if (scroller) scroller.scrollTop = state.feedActiveIndex * h;
|
||||||
if (observer) observer.unobserve(slide);
|
};
|
||||||
|
|
||||||
|
App.feed.isOpen = function() {
|
||||||
|
return !!state.feedOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called whenever new video JSON is appended (e.g. after a prefetch). Lets
|
||||||
|
// the open feed pick up newly buffered slides and extend its window if the
|
||||||
|
// active slide is near the end.
|
||||||
|
App.feed.renderSlides = function() {
|
||||||
|
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
||||||
|
setActive(state.feedActiveIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.reset = function() {
|
||||||
|
slidesByIndex.forEach((slide) => {
|
||||||
destroySlidePlayback(slide);
|
destroySlidePlayback(slide);
|
||||||
slide.remove();
|
slide.remove();
|
||||||
});
|
});
|
||||||
state.feedRenderedIds.clear();
|
slidesByIndex.clear();
|
||||||
state.feedActiveSlide = null;
|
state.feedActiveIndex = -1;
|
||||||
|
const spacer = getTopSpacer();
|
||||||
|
if (spacer) spacer.style.height = '0px';
|
||||||
|
const scroller = getScroller();
|
||||||
if (scroller) scroller.scrollTop = 0;
|
if (scroller) scroller.scrollTop = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,46 +344,29 @@ App.feed = App.feed || {};
|
|||||||
App.player.close();
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.classList.add('open');
|
container.classList.add('open');
|
||||||
container.setAttribute('aria-hidden', 'false');
|
container.setAttribute('aria-hidden', 'false');
|
||||||
document.body.classList.add('feed-mode-open');
|
document.body.classList.add('feed-mode-open');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
App.feed.renderSlides();
|
if (!scrollBound) {
|
||||||
|
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||||
if (startVideoId != null) {
|
window.addEventListener('resize', onResize);
|
||||||
const targetSlide = Array.from(scroller.querySelectorAll('.feed-slide'))
|
scrollBound = true;
|
||||||
.find((slide) => slide.dataset.videoId === String(startVideoId));
|
|
||||||
if (targetSlide) scroller.scrollTop = targetSlide.offsetTop;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.feed-slide').forEach((slide) => observer.observe(slide));
|
// Start from whichever grid video the user was looking at.
|
||||||
|
let startIndex = 0;
|
||||||
|
if (startVideoId != null) {
|
||||||
|
const found = (state.loadedVideos || [])
|
||||||
|
.findIndex((v) => String(v.id) === String(startVideoId));
|
||||||
|
if (found >= 0) startIndex = found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a fresh activation even if the index happens to match.
|
||||||
|
state.feedActiveIndex = -1;
|
||||||
|
setActive(startIndex);
|
||||||
|
scroller.scrollTop = startIndex * slideHeight();
|
||||||
|
|
||||||
App.feed.updateToggleButton();
|
App.feed.updateToggleButton();
|
||||||
App.feed.updateMuteButton();
|
App.feed.updateMuteButton();
|
||||||
@@ -270,8 +376,7 @@ App.feed = App.feed || {};
|
|||||||
const container = document.getElementById('feed-view');
|
const container = document.getElementById('feed-view');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
state.feedOpen = false;
|
state.feedOpen = false;
|
||||||
document.querySelectorAll('.feed-slide').forEach((slide) => destroySlidePlayback(slide));
|
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
||||||
state.feedActiveSlide = null;
|
|
||||||
container.classList.remove('open');
|
container.classList.remove('open');
|
||||||
container.setAttribute('aria-hidden', 'true');
|
container.setAttribute('aria-hidden', 'true');
|
||||||
document.body.classList.remove('feed-mode-open');
|
document.body.classList.remove('feed-mode-open');
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ App.state = {
|
|||||||
loadedVideos: [],
|
loadedVideos: [],
|
||||||
feedOpen: false,
|
feedOpen: false,
|
||||||
feedMuted: true,
|
feedMuted: true,
|
||||||
feedRenderedIds: new Set(),
|
feedActiveIndex: -1,
|
||||||
feedActiveSlide: null,
|
|
||||||
groupCursors: null
|
groupCursors: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user