From ded556df3dac4b25d768d42ce825f86030f07f2b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:03:41 +0530 Subject: [PATCH] virtualise soruce list --- .../resources/player-ui/controls.html | 202 ++++++++++++++++-- 1 file changed, 190 insertions(+), 12 deletions(-) diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html index f4696e979..06ca42334 100644 --- a/composeApp/src/desktopMain/resources/player-ui/controls.html +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -1122,7 +1122,8 @@ } .track-panel.wide { - width: min(92vw, 680px); + width: min(94vw, 860px); + max-height: min(92vh, 760px); } .track-panel.submit { @@ -1404,6 +1405,24 @@ min-height: 0; } + .track-list.virtualized { + display: block; + gap: 0; + contain: content; + } + + .source-virtual-spacer { + position: relative; + width: 100%; + } + + .source-virtual-row { + position: absolute; + left: 0; + right: 0; + top: 0; + } + .track-row { width: 100%; min-height: 42px; @@ -1425,6 +1444,11 @@ min-height: 72px; } + .track-row.source-row { + min-height: 0; + align-items: flex-start; + } + .track-row.disabled { opacity: .45; } @@ -1517,6 +1541,18 @@ white-space: nowrap; } + .source-row .stream-name, + .source-row .stream-subtitle, + .source-row .stream-addon { + display: block; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + -webkit-line-clamp: unset; + -webkit-box-orient: initial; + } + .status-chip, .episode-code { flex: 0 0 auto; @@ -2380,6 +2416,13 @@ let activeModal = ""; let pressedButton = null; let sourceFilterId = ""; + let sourceVirtualKey = ""; + let sourceVirtualItems = []; + let sourceVirtualHeights = []; + let sourceVirtualOffsets = []; + let sourceVirtualTotalHeight = 0; + let sourceVirtualSpacer = null; + let sourceVirtualRenderRaf = 0; let selectedEpisodeSeason = null; let episodeStreamFilterId = ""; let submitIntroDraft = { @@ -2967,10 +3010,69 @@ }); }; - const appendSourceRow = (container, item, onSelect) => { + const SourceRowEstimatedHeight = 116; + const SourceRowGap = 10; + const SourceRowOverscanPx = 720; + + const sourceKeyForItems = items => { + const first = items[0] || {}; + const last = items[items.length - 1] || {}; + return [ + sourceFilterId || "", + items.length, + first.index == null ? "" : first.index, + first.label || "", + last.index == null ? "" : last.index, + last.label || "", + ].join("\u0001"); + }; + + const resetSourceVirtualState = (items, key) => { + sourceVirtualKey = key; + sourceVirtualItems = items; + sourceVirtualHeights = new Array(items.length).fill(SourceRowEstimatedHeight); + sourceVirtualOffsets = []; + sourceVirtualTotalHeight = 0; + sourceVirtualSpacer = null; + window.cancelAnimationFrame(sourceVirtualRenderRaf); + sourceVirtualRenderRaf = 0; + }; + + const rebuildSourceVirtualLayout = () => { + let offset = 0; + sourceVirtualOffsets = sourceVirtualItems.map((_, index) => { + const current = offset; + offset += sourceVirtualHeights[index] || SourceRowEstimatedHeight; + if (index < sourceVirtualItems.length - 1) offset += SourceRowGap; + return current; + }); + sourceVirtualTotalHeight = offset; + if (sourceVirtualSpacer) { + sourceVirtualSpacer.style.height = `${sourceVirtualTotalHeight}px`; + } + }; + + const sourceIndexForOffset = offset => { + let low = 0; + let high = sourceVirtualOffsets.length - 1; + let result = 0; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const rowBottom = sourceVirtualOffsets[mid] + (sourceVirtualHeights[mid] || SourceRowEstimatedHeight); + if (rowBottom < offset) { + low = mid + 1; + } else { + result = mid; + high = mid - 1; + } + } + return result; + }; + + const buildSourceRow = (item, onSelect) => { const row = document.createElement("button"); row.type = "button"; - row.className = `track-row stream-row${item.isCurrent ? " selected" : ""}${item.isEnabled === false ? " disabled" : ""}`; + row.className = `track-row stream-row source-row${item.isCurrent ? " selected" : ""}${item.isEnabled === false ? " disabled" : ""}`; row.disabled = item.isEnabled === false; row.addEventListener("click", event => { event.stopPropagation(); @@ -3001,12 +3103,68 @@ copy.appendChild(subtitle); } - const addon = document.createElement("span"); - addon.className = "stream-addon"; - addon.textContent = item.addonName || ""; - copy.appendChild(addon); + if (item.addonName) { + const addon = document.createElement("span"); + addon.className = "stream-addon"; + addon.textContent = item.addonName; + copy.appendChild(addon); + } row.appendChild(copy); - container.appendChild(row); + return row; + }; + + const renderSourceVirtualRows = () => { + sourceVirtualRenderRaf = 0; + if (!sourceVirtualSpacer) return; + const count = sourceVirtualItems.length; + if (count === 0) return; + + const viewportTop = Math.max(0, sourceList.scrollTop - SourceRowOverscanPx); + const viewportBottom = sourceList.scrollTop + sourceList.clientHeight + SourceRowOverscanPx; + const start = sourceIndexForOffset(viewportTop); + let end = start; + while (end < count && sourceVirtualOffsets[end] <= viewportBottom) { + end += 1; + } + end = Math.min(count, Math.max(end + 1, start + 1)); + + sourceVirtualSpacer.textContent = ""; + const fragment = document.createDocumentFragment(); + const rendered = []; + for (let index = start; index < end; index += 1) { + const item = sourceVirtualItems[index]; + const wrapper = document.createElement("div"); + wrapper.className = "source-virtual-row"; + wrapper.style.transform = `translateY(${sourceVirtualOffsets[index]}px)`; + const row = buildSourceRow(item, selected => { + send("selectSource", Number(selected.index) || 0); + window.setTimeout(closePlayerModal, 120); + }); + wrapper.appendChild(row); + fragment.appendChild(wrapper); + rendered.push({ index, wrapper }); + } + sourceVirtualSpacer.appendChild(fragment); + + window.requestAnimationFrame(() => { + let changed = false; + rendered.forEach(({ index, wrapper }) => { + const measured = Math.ceil(wrapper.getBoundingClientRect().height); + if (measured > 0 && Math.abs((sourceVirtualHeights[index] || SourceRowEstimatedHeight) - measured) > 1) { + sourceVirtualHeights[index] = measured; + changed = true; + } + }); + if (changed) { + rebuildSourceVirtualLayout(); + requestSourceVirtualRender(); + } + }); + }; + + const requestSourceVirtualRender = () => { + if (sourceVirtualRenderRaf) return; + sourceVirtualRenderRaf = window.requestAnimationFrame(renderSourceVirtualRows); }; const renderSourceModal = () => { @@ -3020,25 +3178,40 @@ } renderFilterRow(sourceFilterList, filters, sourceFilterId, id => { sourceFilterId = id; + sourceList.scrollTop = 0; renderSourceModal(); }); sourceList.textContent = ""; + sourceList.classList.remove("virtualized"); let items = normalizeItems(state.sourceItems); if (sourceFilterId) { items = items.filter(item => String(item.filterId || "") === sourceFilterId); } if (items.length === 0) { + sourceVirtualItems = []; + sourceVirtualSpacer = null; + window.cancelAnimationFrame(sourceVirtualRenderRaf); + sourceVirtualRenderRaf = 0; appendEmptyTrackState( sourceList, state.sourceIsLoading ? "Loading streams..." : (state.noStreamsLabel || "No streams found"), ); return; } - items.forEach(item => appendSourceRow(sourceList, item, selected => { - send("selectSource", Number(selected.index) || 0); - window.setTimeout(closePlayerModal, 120); - })); + const nextKey = sourceKeyForItems(items); + if (nextKey !== sourceVirtualKey) { + resetSourceVirtualState(items, nextKey); + sourceList.scrollTop = 0; + } else { + sourceVirtualItems = items; + } + sourceList.classList.add("virtualized"); + sourceVirtualSpacer = document.createElement("div"); + sourceVirtualSpacer.className = "source-virtual-spacer"; + sourceList.appendChild(sourceVirtualSpacer); + rebuildSourceVirtualLayout(); + renderSourceVirtualRows(); }; const appendEpisodeRow = (container, item) => { @@ -3592,6 +3765,11 @@ event.stopPropagation(); closePlayerModal(); }); + sourceList.addEventListener("scroll", () => { + if (activeModal === "sources") { + requestSourceVirtualRender(); + } + }, { passive: true }); episodesCloseButton.addEventListener("click", event => { event.stopPropagation(); closePlayerModal();