diff --git a/frontend/js/feed.js b/frontend/js/feed.js index eaf89f9..c66c9ff 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -316,6 +316,20 @@ App.feed = App.feed || {}; slide._sharedControlCleanups = cleanups; }; + // The tallest rendition worth decoding for this pane: its own height in + // device pixels, rounded up to the next common rendition so a pane a little + // over 360px doesn't get 360p. Returns 0 (no cap) for a single full-screen + // pane, which is the old behaviour. + const RENDITION_STEPS = [240, 360, 480, 720, 1080, 1440, 2160]; + + const paneHeightCap = function(pane) { + if (paneCount() <= 1) return 0; + const box = pane.getBoundingClientRect(); + if (!box.height) return 0; + const needed = box.height * (window.devicePixelRatio || 1); + return RENDITION_STEPS.find((step) => step >= needed) || 0; + }; + const loadSlideSource = function(slide, videoData, autoplay) { const video = slide.querySelector('.feed-video'); if (!video) return; @@ -346,7 +360,7 @@ App.feed = App.feed || {}; const resolved = slide._formatOverride ? App.videos.resolveSourceForFormat(videoData, slide._formatOverride) - : App.videos.resolveStreamSource(videoData); + : App.videos.resolveStreamSource(videoData, { maxHeight: paneHeightCap(slide) }); if (!resolved || !resolved.url) { // No playable source -- treat exactly like a load failure so the // clip is dropped from the queue and the next one takes its place. @@ -359,7 +373,11 @@ App.feed = App.feed || {}; const isHls = App.videos.classifySource(resolved).isHls; video.muted = slide._muted !== false; - video.preload = 'auto'; + // The step being watched buffers properly; the one preloaded behind it + // only needs enough to start instantly on the swipe. With four panes + // that is the difference between four extra streams downloading and + // four holding a few seconds each. + video.preload = autoplay ? 'auto' : 'metadata'; video._tearingDown = false; applyResume(video, videoData && videoData.id, resolved.isLive); @@ -370,7 +388,16 @@ App.feed = App.feed || {}; }; const attachHls = (HlsLib) => { - const hls = new HlsLib(); + const split = paneCount() > 1; + const hls = new HlsLib(split ? { + // Never fetch a rendition larger than the pane it draws into. + capLevelToPlayerSize: true, + // Several streams at once, each holding a minute of video, is + // memory and demuxing work for footage nobody has reached yet. + maxBufferLength: 10, + maxMaxBufferLength: 20, + backBufferLength: 10 + } : {}); video._hlsPlayer = hls; hls.loadSource(streamUrl); hls.attachMedia(video); @@ -785,6 +812,10 @@ App.feed = App.feed || {}; // shifts every rendered slide and lands the reader on the wrong videos. const windowBounds = function(activeIndex) { const per = paneCount(); + // The floor of one step is not a rounding guard: it is what guarantees + // the step after this one always exists, however many panes there are. + // Every panel's next video lives in that step, so lowering it below one + // would leave a panel with nothing buffered to swipe to. return { start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))), end: Math.min(stepCount() - 1, @@ -857,6 +888,10 @@ App.feed = App.feed || {}; slidesByIndex.forEach((slide, i) => { if (i === clamped) return; + // Same floor, same reason: at least the next step is preloaded, so + // every panel has its next video ready before the swipe. It is + // preloaded for *all* of that step's panes, which is what makes the + // guarantee hold per panel rather than only for the first. const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount())); panesOf(slide).forEach((pane) => { if (i > clamped && i <= clamped + preloadAhead) { diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 57309c9..39074d4 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -2003,6 +2003,14 @@ App.videos = App.videos || {}; if (applyPreferredQuality) { const preferredQuality = App.storage.getPreferredQuality(); preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality); + // A caller showing this in a fraction of the screen -- a split reels + // panel -- caps it further. Decoding a 1080p stream into a quarter + // of a phone screen costs the same as decoding it full size, and + // four of those at once is what makes a split view stutter. + const maxHeight = App.videos.coerceNumber(options && options.maxHeight); + if (maxHeight > 0) { + preferredHeight = preferredHeight ? Math.min(preferredHeight, maxHeight) : maxHeight; + } } const sources = App.videos.rankFormats(meta.formats, preferredHeight).map((fmt) => { diff --git a/tests/smoke_reels.py b/tests/smoke_reels.py new file mode 100755 index 0000000..3bd7f95 --- /dev/null +++ b/tests/smoke_reels.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Reels split-panel smoke tests. + +Run against a locally running backend: + + backend/main.py & + .venv/bin/python tests/smoke_reels.py + +The load-bearing check is the preload guarantee: every panel must have its +next video buffered before the reader swipes. With locked scrolling that is +the same panel position in the next step, so it holds only while the next +step is both built and preloaded for all of its panes -- which in turn rests +on the floor of one step in windowBounds() and in setActive()'s preloadAhead. +Both shrink as panes are added, and without the floor a wide split would +leave panels with nothing to swipe to. +""" +import sys +from playwright.sync_api import sync_playwright + +BASE = "http://127.0.0.1:5000/" +SERVER = "https://hottubapp.io" +CHANNEL = "xvideos" + +SEED = """([server, channel]) => { + localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] })); + localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } })); + localStorage.removeItem('session'); +}""" + +LAYOUT = """() => { + const slide = document.querySelector('.feed-slide.is-active'); + if (!slide) return null; + const panes = Array.from(slide.querySelectorAll('.feed-pane')); + const bounds = slide.getBoundingClientRect(); + return { + count: panes.length, + reported: App.feed.paneCount(), + videos: panes.map(p => p.dataset.videoId), + // Controls positioned for a full viewport end up outside a short pane, + // clipped by its overflow and unreachable. + escaping: panes.reduce((bad, p, i) => { + const pr = p.getBoundingClientRect(); + ['.feed-fav-btn', '.feed-pip-btn', '.feed-format-btn', '.feed-pane-tools'] + .forEach((sel) => { + const el = p.querySelector(sel); + if (!el || el.hidden) return; + const r = el.getBoundingClientRect(); + if (r.top < pr.top - 1 || r.bottom > pr.bottom + 1 || + r.left < pr.left - 1 || r.right > pr.right + 1) bad.push(i + sel); + }); + return bad; + }, []), + within_slide: panes.every(p => { + const r = p.getBoundingClientRect(); + return r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1; + }), + }; +}""" + +PRELOAD = """() => { + const active = App.state.feedActiveIndex; + const rows = {}; + document.querySelectorAll('.feed-slide').forEach((slide) => { + const panes = Array.from(slide.querySelectorAll('.feed-pane')); + rows[Number(slide.dataset.step) - active] = panes.map((p) => { + const v = p.querySelector('.feed-video'); + return !!(v && (v.getAttribute('src') || v._hlsPlayer)); + }); + }); + return { per: App.feed.paneCount(), next: rows[1] || null, current: rows[0] || null }; +}""" + + +class Checks: + def __init__(self): + self.failed = 0 + + def ok(self, label, condition, detail=""): + if not condition: + self.failed += 1 + print(f" [{'PASS' if condition else 'FAIL'}] {label}" + + (f" -- {detail}" if detail and not condition else "")) + + +def open_reels(page): + page.goto(BASE, wait_until="domcontentloaded") + page.evaluate(SEED, [SERVER, CHANNEL]) + page.goto(BASE, wait_until="load") + try: + page.wait_for_selector(".video-card", timeout=90000) + except Exception: + page.goto(BASE, wait_until="load") + page.wait_for_selector(".video-card", timeout=90000) + page.wait_for_timeout(4000) + page.evaluate("() => App.feed.toggle()") + page.wait_for_selector(".feed-slide", timeout=20000) + page.wait_for_timeout(3000) + + +def split(page, selector): + page.click(".feed-slide.is-active " + selector) + page.wait_for_timeout(4000) + + +def main(): + c = Checks() + with sync_playwright() as p: + browser = p.chromium.launch(args=[ + "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", + "--renderer-process-limit=1", + ]) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + open_reels(page) + + print("\nsingle panel") + one = page.evaluate(LAYOUT) + c.ok("reels opens with one panel", one and one["count"] == 1, str(one)) + + print("\nsplit right, then split the new panel below") + split(page, ".feed-pane .feed-pane-split-right") + split(page, ".feed-pane:last-of-type .feed-pane-split-down") + three = page.evaluate(LAYOUT) + c.ok("three panels after two splits", three["count"] == 3, str(three["count"])) + c.ok("paneCount agrees with the DOM", three["reported"] == three["count"]) + c.ok("every panel shows a different video", + len(set(three["videos"])) == len(three["videos"]), str(three["videos"])) + c.ok("panels fit inside the slide", three["within_slide"]) + c.ok("no control escapes its panel", not three["escaping"], str(three["escaping"])) + + print("\npreload") + pre = page.evaluate(PRELOAD) + c.ok("the next step exists", pre["next"] is not None) + c.ok("every panel of the current step is loaded", pre["current"] and all(pre["current"]), + str(pre["current"])) + # The point of the exercise: nobody should swipe into an empty panel. + c.ok("every panel has its next video preloaded", + pre["next"] is not None and all(pre["next"]) and len(pre["next"]) == pre["per"], + str(pre["next"])) + + print("\none swipe advances every panel") + before = page.evaluate(LAYOUT)["videos"] + page.evaluate("() => { const s = document.getElementById('feed-scroll');" + " s.scrollTop += s.clientHeight; }") + page.wait_for_timeout(3500) + after = page.evaluate(LAYOUT)["videos"] + c.ok("all panels moved on", all(v not in before for v in after if v), + f"{before} -> {after}") + c.ok("still preloaded after the swipe", + all(page.evaluate(PRELOAD)["next"] or [False])) + + print("\nper-panel audio") + page.click(".feed-slide.is-active .feed-pane:first-of-type .feed-pane-mute") + page.wait_for_timeout(1200) + page.evaluate("() => App.feed.renderSlides()") # re-activate the step + page.wait_for_timeout(1500) + muted = page.evaluate("""() => Array.from( + document.querySelectorAll('.feed-slide.is-active .feed-pane .feed-video') + ).map(v => v.muted)""") + c.ok("only the unmuted panel has sound", muted and muted[0] is False + and all(muted[1:]), str(muted)) + + print("\nclose a panel") + page.click(".feed-slide.is-active .feed-pane .feed-pane-close") + page.wait_for_timeout(3500) + closed = page.evaluate(LAYOUT) + c.ok("closing collapses the split", closed["count"] == 2, str(closed["count"])) + c.ok("survivors still fit the slide", closed["within_slide"]) + + browser.close() + + print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed") + return 1 if c.failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit_formats.js b/tests/unit_formats.js new file mode 100755 index 0000000..732b980 --- /dev/null +++ b/tests/unit_formats.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/* Format selection, tested without a browser. + * + * node tests/unit_formats.js + * + * Picking a rendition is pure: a list of formats in, one URL out. That makes it + * the one part of playback that can be checked in a second, with no server, no + * Chromium and no network -- which matters, because the height cap a split + * reels panel applies is the difference between decoding four 1080p streams and + * four 480p ones. + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +// Enough of a browser for videos.js to finish loading. It builds a few +// IntersectionObservers and reads matchMedia at module scope; nothing below +// touches the DOM. +const noop = () => {}; +const element = () => ({ + style: { setProperty: noop, removeProperty: noop }, + classList: { add: noop, remove: noop, toggle: noop, contains: () => false }, + dataset: {}, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener: noop, + removeEventListener: noop, + appendChild: noop, + removeChild: noop, + remove: noop, + getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }), + setAttribute: noop, + removeAttribute: noop, + getAttribute: () => null, + cloneNode: element, + content: { firstElementChild: { cloneNode: element } }, + children: [], + childElementCount: 0, +}); + +const sandbox = { + console, + setTimeout, + clearTimeout, + URL, + Image: function () { return element(); }, + IntersectionObserver: function () { + return { observe: noop, unobserve: noop, disconnect: noop }; + }, + requestAnimationFrame: noop, + requestIdleCallback: noop, + performance: { now: () => 0 }, + localStorage: { getItem: () => null, setItem: noop, removeItem: noop }, +}; +sandbox.addEventListener = noop; +sandbox.removeEventListener = noop; +sandbox.window = sandbox; +sandbox.self = sandbox; +sandbox.globalThis = sandbox; +sandbox.document = { + getElementById: () => null, + createElement: element, + querySelectorAll: () => [], + addEventListener: noop, + documentElement: element(), + body: element(), + head: element(), +}; +sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop }); +sandbox.window.location = { href: 'http://localhost/' }; + +const context = vm.createContext(sandbox); +const load = (file) => vm.runInContext( + fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', file), 'utf8'), context, file); + +// videos.js reaches for these siblings when a card is built; none of the +// functions under test do. +sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false, + setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } }; +load('videos.js'); + +const { rankFormats, resolveStreamSource, resolveStreamSources } = sandbox.App.videos; + +let failed = 0; +const ok = (label, cond, detail) => { + if (!cond) failed++; + console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : '')); +}; + +const formats = [ + { url: 'u240', height: 240, vcodec: 'avc1' }, + { url: 'u480', height: 480, vcodec: 'avc1' }, + { url: 'u720', height: 720, vcodec: 'avc1' }, + { url: 'u1080', height: 1080, vcodec: 'avc1' }, +]; +const video = { id: 'v1', url: 'https://example.com/watch', meta: { formats: formats } }; +const heightOf = (src) => (formats.find((f) => f.url === src.url) || {}).height; + +console.log('\nranking'); +ok('no ceiling takes the best', rankFormats(formats, null)[0].height === 1080); +ok('a ceiling takes the best at or below it', rankFormats(formats, 720)[0].height === 720); +ok('an exact ceiling is allowed', rankFormats(formats, 480)[0].height === 480); +ok('below every rendition still returns one', rankFormats(formats, 100)[0].height === 240, + String(rankFormats(formats, 100)[0].height)); +ok('everything stays reachable as fallback', rankFormats(formats, 480).length === formats.length); + +console.log('\nthe cap a split panel applies'); +sandbox.App.storage.getPreferredQuality = () => 'auto'; +ok('uncapped panel gets the best', heightOf(resolveStreamSource(video)) === 1080); +ok('a quarter-screen panel gets a quarter-screen rendition', + heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480); +ok('the cap survives into the fallback order', + heightOf(resolveStreamSources(video, { maxHeight: 480 })[0]) === 480); + +console.log('\nthe cap and the quality preference are both ceilings'); +sandbox.App.storage.getPreferredQuality = () => '720'; +ok('preference alone caps at 720', heightOf(resolveStreamSource(video)) === 720); +ok('the tighter of the two wins (panel)', + heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480); +sandbox.App.storage.getPreferredQuality = () => '480'; +ok('the tighter of the two wins (preference)', + heightOf(resolveStreamSource(video, { maxHeight: 720 })) === 480); +ok('a cap never raises the preference', + heightOf(resolveStreamSource(video, { maxHeight: 2160 })) === 480); + +console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`); +process.exit(failed ? 1 : 0);