Compare commits

17 Commits

Author SHA1 Message Date
Simon
b8a51d9ee2 Keep every panel playing on iOS, and give one of them the sound
iOS gives a page one audible video at a time. Start a second one with
sound and the system pauses the first, so on an iPhone a split of two
showed one panel playing and one stopped -- the feed-wide unmute set every
panel audible, and the panels then took the audio session from each other
in turn.

Muted video carries no such limit, so where the rule applies exactly one
panel keeps its sound and the rest keep their picture: four pictures moving
is what the split is for, and four audio tracks at once was never the
point. The sound follows whichever panel is asked for it, and a panel the
system stopped on the way is started again.

Nothing in the platform announces the rule, and by the time the symptom
shows -- a video we started, stopped by something that isn't us -- a panel
has already died, so it is read off the device rather than discovered.
Everywhere else nothing changes: panels keep their own sound.

No iPhone here, so the rule is installed into Chromium and the real feed is
driven through it. The last check breaks it on purpose -- unmuting every
panel by hand does stop one -- so the simulation can't quietly become a
no-op. Against the code before this commit the test fails seven checks,
showing exactly what was reported: both panels unmuted, the first paused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 16:39:17 +00:00
Simon
b4bc90372d Load a video before showing the player, not after
Opening a video put an empty black player up at once and spun at the
viewer until the first frame arrived -- which, between resolving the
formats and the first bytes of a stream, is routinely a couple of seconds
of nothing to look at.

The session now starts off-screen: everything is built and loading, but the
card that was clicked keeps its own spinner, the page stays where it was,
and the player appears when the video has something to show. Failures
reveal it too, since the error and its retry live inside the player, and so
does a five-second timeout -- a stream that is merely slow is better
watched from inside a player that can be closed than from a card that looks
stuck.

The video is muted while it loads out of sight and restored on the way in,
because nothing should be heard from a player that isn't there. It is
hidden with opacity rather than display:none: iOS won't load a display:none
video, and the HUD's children opt back into pointer events, so they are
told not to as well -- an invisible close button must not eat a card's
click.

isActive() replaces the checks that asked "is the player open?" and meant
"is there a session": a pending one owns the same history entry, the same
card spinner and the same requests, so Escape cancels it, the back gesture
cancels it, another video takes over from it, and an update can't reload
the page out from under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 16:39:03 +00:00
Simon
4b03789ef2 Hand over the whole item from the info panel
The panel is where you go to see exactly what the server said about a
video, and every time that is worth reporting somewhere it has to be
retyped from the screen. Copy JSON hands over the object instead: the
listing item, plus the extractor's payload once that has resolved.

What it copies is built where the rows are built, so the button and the
panel can't disagree about what "this video" means -- and it is the values
rather than the rendering, so a duration of 0 stays 0 instead of becoming
the panel's dash.

navigator.clipboard needs a secure context, which the app has on https and
does not on a plain-http LAN address, so the old execCommand path sits
behind it.

The test drives the real clipboard rather than the function, and the
awkward parts of a real item -- nested http_headers, a tag array, a zero,
an empty string -- are in the fixture for that reason. It and two others
also pick up smoke_grid's lean Chromium flags, without which they get
themselves killed when run alongside everything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 15:47:58 +00:00
Simon
051dae98cb Ask a video's own page for a thumbnail the listing lost
sxyprn signs its CDN paths with an expiry, and the URLs the listing hands
us have generally passed theirs. They only look alive while Cloudflare
still has the bytes: add a cache-buster to one that returns 200 and it
returns 404, every time, for every one tried. The newest posts are the ones
nobody fetched while the URL was valid, so they are the ones that arrive as
holes in the grid -- which is exactly where this was reported.

Neither route can help, because both ask for the same dead address, and the
right one can't be derived: the token signs the whole path, so swapping
`full.jpg` for `small.jpg` or `vid` for `img` is just another 404. The post
page, though, always carries a freshly signed one in og:image. So when a
thumbnail has failed every way we know to ask for it, /api/poster fetches
that page and reads the picture off it -- streamed, capped, and only if what
comes back is HTML, since a page URL that turns out to redirect to the video
must cost one buffer rather than a download. Answers are cached, including
"nothing", which is the honest answer for a post that has been deleted.

Finding the page is the other half. A listing item points at the Hot Tub
server's proxy, which answers by redirecting to the video, so there is no
page there to read -- but the item also carries the Referer the media needs,
and that names the site. Its origin plus the path the proxy was going to
fetch is the page a browser would open.

Also fixes what this exposed: the retry ladder disarmed itself on its last
step, so the failure that means "the picture is gone, not the route" was
never heard by anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 15:47:47 +00:00
Simon
a795442634 Choose a channel by what it is, not by its name in a list
A <select> of ninety channels tells the reader almost nothing: a wall of
bare names, nothing to say which site a name belongs to or what it
carries, and no way to look for one. The server has been sending far more
than the name all along -- a favicon, a description, tags, its own
groupings, and whether a channel still says "work in progress" -- so the
picker shows that.

It opens as a dialog in the site's own style: a search field, then the
server's groups as sections, each led by an "All <group>" row that browses
the whole group. Search matches the name, the id, the description, the
tags and the group, so "jav" finds Tokyo Motion (whose name never says it)
and "leaks" finds the OnlyFans mirrors. Arrows and Enter walk the list,
Escape closes it, and opening it with nothing typed scrolls to the channel
already being read. On a phone it fills the screen and leaves the field
unfocused -- the keyboard would cover the thing being chosen from.

Favicons load through attachThumbnail, so they get the same route race,
proxy fallback and retries as every other remote picture, with the
channel's initial behind them for the ones that never arrive.

The <select> was also where the command palette read its channel actions,
so the list lives in App.ui.channels now and the palette asks for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-19 08:27:43 +00:00
Simon
b478c51551 Keep one junk thumbnail from taking the page down with it
sxyprn's "latest" listing sends items whose thumb is the bare string
"https:". Resolved against the page -- which is what new URL(url, location)
does with anything that isn't absolute -- that becomes our own address, so
the card raced our own HTML as if it were a picture, pinned our origin to
the proxy for the rest of the session, and asked /api/image to fetch
"https:" (a 400, every time). An address that doesn't stand on its own is
no thumbnail at all, and is now treated as one: no src, no race, no
request, and the card keeps its placeholder.

While in here: a thumbnail that failed had exactly one more chance, the
proxy, and images already on the proxy route had none at all -- so one
refused connection left a card empty for as long as it stayed mounted. A
failure now walks a short ladder instead: the other route, then both again
after a pause. Bounded and backed off, and dropped the moment the card is
rebound, so a page of genuinely dead images costs a handful of requests
rather than a storm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-18 19:31:05 +00:00
Simon
191c81e55e Reach picture-in-picture the way iOS offers it
Safari on iPhone and iPad has picture-in-picture but never implemented
requestPictureInPicture; it exposes WebKit's older presentation-mode switch
instead, and document.pictureInPictureEnabled is undefined there. Every
capability check in the player and the feed was that one property, so they
all answered "no" and the button was hidden outright on the platform where
people most want it.

The difference is confined to customPlayer: supportsPiP, pipElement, enterPiP
and exitPiP speak for both APIs, and bindPiPEvents re-fires WebKit's
webkitpresentationmodechanged -- which does not bubble -- as the standard
enter/leave events, so the feed's delegated listeners, the pane pin and the
media-key stepping work unchanged. Capability is read from the method's
presence rather than webkitSupportsPresentationMode(), which answers false
until a video track is loaded and would hide the button on preload="none"
feed videos.

There is no iPhone here, so smoke_ios_pip.py reshapes the browser to iOS's
API surface -- deleting the standard entry points and installing WebKit's --
and drives the button through it. That covers what actually broke: which API
the code reaches for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 21:49:16 +00:00
Simon
2abdc31f56 Put the reel itself in the picture-in-picture window, so it scrolls
A video picture-in-picture window renders one <video>'s frames and nothing
else, which is why moving through the reel from it had to be smuggled in
through media keys. Document picture-in-picture opens a real document
instead, so the feed is moved into it: #feed-view and its whole subtree are
appended to the new window's body. Nothing is copied and no <video> is
re-created, so playback continues across the move and the scroll-snap list,
the panes, the split tree and every control keep working -- they are the same
elements, in another window. Scrolling the window scrolls the reel, because
it is the reel.

The lookups in feed.js now go through the cached #feed-view root rather than
document.getElementById, since after the move the feed is no longer in this
document, and the HUD-idle class rides on that root so it travels with it.
The page underneath drops back to the grid while the feed is out, and gets it
back when the window closes.

The video window stays as the fallback: it is Chrome-only, needs a user
gesture, and a hidden tab has none -- so auto-PiP on tab switch is still the
old path. It is suppressed while a document window is open, or the browser
would tear the video out of it on the next switch.

Also fixes the picture-in-picture button, which referenced an undefined
`pane` in bindSharedControls (the parameter was named `slide`) and threw into
a swallowed promise rejection on every click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 20:45:49 +00:00
Simon
76a078b9b5 Move through the reel from the picture-in-picture window
A picture-in-picture window shows one video's frames and nothing else --
no markup, no scrolling. What it does have is the controls the page
declares through the Media Session API, so moving through the reel from
the window means its next and previous buttons. Those are the same
controls that appear on a lock screen, on headphones and on media keys,
and with no window open they move the feed itself.

The window rides the pane's own video. That matters for the automatic
case, and it is where the first attempt went wrong: a hidden stand-in
element was used so the window could survive the feed advancing, but a
browser only carries a video out to a window by itself if that video is
already playing, and a hidden one never is. So opening it by hand worked
-- a click is a gesture, and an explicit request needs one -- while
switching tabs could not, and the autoPictureInPicture attribute meant to
handle that case was sitting on a different element than the code used.
Both halves now point at the same video.

Stepping is what a pane's video normally cannot survive, since the feed
destroys a step's videos when it moves on. While a pane is in the window
it is pinned against teardown and its source is swapped in place: the pane
becomes the video being watched, title and poster included, and the feed
is rebuilt around wherever it ended when the window closes. Two panes can
name the same video while that is going on; the rebuild puts the steps
back in order.

The leave handler releases the pin on any leave that finds nothing left in
a window, rather than insisting the element match exactly. The two ways of
being wrong are not the same size: releasing too readily costs a rebuild
nobody sees, while a pin that never releases leaves the feed unable to
recycle that slide for the rest of the session.

Whether a window opens on a tab switch is browser policy, not ours:
Safari honours the attribute, Chrome honours it for installed apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 20:31:32 +00:00
Simon
451bf0f983 Pick formats by decode cost, and fix auto picture-in-picture
Two things, both about playing several videos at once.

Capping the resolution per panel wasn't enough, because pixel count isn't
the only cost. A split panel now also prefers a progressive file over HLS
-- every HLS panel runs its own JavaScript demuxer over every segment, so
four panels means four media pipelines doing work a plain MP4 skips
entirely -- and H.264 over AV1 or VP9, which are often decoded in software
and are a cliff rather than a gradient, and 30fps over 60. The height
ceiling still comes first, so cheapness cannot argue a panel into a bigger
picture than it should have, and every format stays reachable as fallback.
The preloaded step's hls.js instances now park after buffering one
fragment and resume when the reader swipes to them, instead of fetching
and demuxing ahead for a step nobody reached.

Auto picture-in-picture had been implemented since the custom player was
written and had never worked. requestPictureInPicture() from a
visibilitychange handler carries no user activation, browsers refuse those,
and .catch(() => {}) swallowed the refusal -- so it failed silently every
time, in the reels feed and the standalone player alike. The declarative
autoPictureInPicture attribute is the form made for this: the browser is
told in advance which video should follow the reader out. The imperative
call stays as a fallback.

With panels there are several candidates and only one window, so binding
every pane made them race for it. The feed picks one deliberately -- the
panel you can hear, or the first if they are all muted -- re-picks when the
step or a mute switch changes, and releases it on close.

Whether a window actually opens is browser policy, not ours: Safari honours
the attribute, Chrome honours it for installed apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 20:16:08 +00:00
Simon
764e3416a3 Fit the rendition to the panel it plays in
Four panels stutter, and the reason isn't scheduling: a quarter-screen
panel was still being handed a full-screen stream. Decoding 1080p into a
quarter of the screen costs exactly what decoding it full size costs, and
four of those at once is past what most GPUs will decode in hardware --
after which it falls back to software and the wheels come off.

So a split panel now caps by its own height in device pixels, rounded up
to the next standard rendition, and hls.js is told the same thing through
capLevelToPlayerSize since an adaptive stream picks its own. Four panels
on a 1080p screen land near 480p each: roughly a quarter of the pixels to
decode. Its buffers shrink too -- several instances each holding a minute
of video is memory and demuxing for footage nobody has reached.

The preloaded step keeps its guarantee but gets cheaper with it: those
panes use preload=metadata rather than auto, so every panel still has its
next video ready to start instantly without four more streams competing
for bandwidth with the four being watched.

The floors that make that preload guarantee hold -- one step, in both
windowBounds and preloadAhead -- now say so. Both are divided by the pane
count, and dropping either below one would leave a panel with nothing
buffered to swipe to.

Two tests. tests/unit_formats.js runs the rendition maths in node with no
browser, server or network, in under a second: picking a format is a list
in and a URL out, and it is the cheapest thing in the repo to assert.
tests/smoke_reels.py covers the panels themselves -- splitting, nesting,
controls staying inside short panes, one swipe advancing every panel,
per-panel audio surviving re-activation, and the preload guarantee.

What none of this establishes is whether four streams now play smoothly
on real hardware. Headless Chromium has no GPU decode, so it cannot say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 18:47:36 +00:00
Simon
f0df53365d Split the reels view into panels
A panel can be split to the right or below, and the panel that appears can
be split again, so any arrangement is reachable. The layout is a binary
tree and the leaves, read in order, are the panels of a step.

Scrolling drives all of them: with N panels a step covers N videos and one
swipe advances the whole set. That runs through every index in the feed --
opening on a video, realigning after a rotation, the prefetch buffer, the
render window -- all of which now convert between a video and the step
that holds it.

Each panel has its own sound, so two can play at once if that is what you
want. A new panel inherits the feed-wide setting rather than starting
muted, so a step built later doesn't disagree with what is already on
screen, and the feed-wide button reads as muted only while every panel is.

Two things fall out of panels that are worth knowing. The render window
narrows as panels are added -- five steps ahead of a four-panel split
would be twenty live <video> elements -- so splitting does not multiply
decoding. And splitting rebuilds around the video you are on rather than
keeping it in the panel you split from: steps are aligned to the panel
count, so it stays on screen but not necessarily first.

The per-video helpers were already written against an element holding one
video's controls, so they took a panel unchanged; a slide became the
container that fans out over them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 18:23:51 +00:00
Simon
6e5ab68a94 Prepare cards ahead of the scroll, and insert them in batches
DOM work cannot leave the main thread -- a worker has no document, and
nodes are not transferable -- so a card can never be compiled elsewhere.
It can be compiled *earlier*. The page already prefetches the next page's
JSON and warms its thumbnails; this does the same for the cards those
items will need, building and binding them while the browser is idle and
handing them over ready when the reader arrives.

Alongside that, three things that keep a frame from being held too long,
which is what smoothness actually reduces to when the work has nowhere
else to go:

Mounting and filling are drained against a 4ms budget rather than all at
once. Filling in one pass was a regression I introduced with the fling
deferral: it moved the stall from during the fling to the end of it.

A frame's mounts go into a DocumentFragment and enter the document in one
insertion, with filling afterwards so nothing reads layout mid-insert.

The pool is topped up with card shells during idle, so a mount during a
scroll is a rebind and not a construction: cards built mid-scroll fell
from 24 to 5 across profiling runs.

Also reverted, with its numbers kept in a comment: narrowing the overscan
during a fling. It reads like an obvious saving and measures as the
opposite -- a tight window makes cards leave and re-enter it, and mount
churn went from 88 to 155 with blocked time from 3.6s to 4.3s.

What this does not do is reduce total blocked time. Roughly three
quarters of it is browser style, layout, paint and decode for each card
shown, which no amount of scheduling removes. The deep stalls get
shorter; the thread stays busy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 19:04:10 +00:00
Simon
e603111d70 Don't fill cards the reader is flinging past
Profiling the reported stutter on a fixed 400-card grid: 30 cards mounted
during one fast scroll, 693ms of blocked main thread -- about 23ms per
card. Building the card is 15.6ms of that. The rest is what a mount sets
off: a thumbnail request and decode, the height correction its load
triggers, a forced layout to measure the title, and an /api/resolve call
that runs yt-dlp on the server. Measured separately, a single fast scroll
fired twelve of those, peaking at nine a second, for videos the reader
never stopped on.

None of it is work anyone asked for while the list is moving. So a mount
during a fling now only places the card: right size, right position, text
and heart in place, so the grid and the scrollbar stay exactly correct.
Everything that costs waits 140ms for the scroll to settle, and then runs
only for the cards still on screen. Whatever was scrolled past is
unmounted having cost almost nothing.

The threshold is 1600px/s, well above a deliberate scroll, so reading at
a normal pace behaves as it did. The visible trade is that flinging
through a long list shows card text over an empty thumbnail box until you
slow down.

Roughly half the blocked time was browser style, layout, paint and decode
that no amount of restructuring removes -- this avoids provoking that work
for cards nobody looks at, rather than making it cheaper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 18:47:33 +00:00
Simon
7624ca559a Close three holes in the card release path
A second review pass over the pool. All three are the same shape: state
or a listener outliving the video it belonged to.

attachProxyFallback replaces whatever fallback an image currently has,
so a call arriving late -- a race hitting its patience timeout after the
card was recycled -- took away the live listener and left a dead one, and
the new video's thumbnail would then fail with nothing behind it. This
one was self-inflicted: the detach came in last round to stop the
listeners accumulating, and introduced the clobber. It is token-guarded
now, like every other path that can arrive late.

The favourite pop is cleared by animationend, which never fires on a card
release() has already detached -- detached elements run no animations. So
the class rode into the pool and replayed on the next video the card
showed. Favourite something and flick-scroll to see it.

And withOrigin compared a token that dataset reports as undefined for an
unstamped card, which matched every unstamped card instead of none --
failing open in the guard whose whole purpose is noticing that the grid
has recycled the card out from under the player.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 15:57:52 +00:00
Simon
49992c1db0 Recycle grid cards instead of rebuilding them
Scrolling the grid did nothing but destroy cards and build near-identical
ones back: a template string parsed as innerHTML, ten querySelectors, and
a listener per interactive element, every time a card entered the window.
The virtualizer now keeps a pool and rebinds a card it already has --
18us against 136us to build one, and 74-83% of mounts are served from it.

Two things had to change first. Nothing on a card may close over the
video it is showing, because the card outlives the video, so every
interaction moved to one delegated listener per event type on the grid.
And every card now has the same shape whatever it shows: the optional
parts are always present and hidden when unused, so any pooled card fits
any video. That needed a global [hidden] rule, since .live-badge and
.video-tags carry their own display.

The rest is the release path, which is where this design lives or dies.
A thumbnail carries a generation, so a race or a proxy fallback settling
after the card moved on cannot paint over the video now showing. The
player stamps the card it was opened from, so a recycled element stops
answering for it. The reveal handler, the entrance-animation listener and
the hover preview are all taken back off. Anything missed here surfaces
as one video's title, thumbnail or heart on another video's card, which
is what the smoke suite scrolls back and forth to catch.

Two incidental fixes found while measuring: bindCard no longer writes a
data-tag per tag button (dataset is a proxy, and that alone cost more
than the rest of a rebind put together -- the handler reads the label off
the button), and favorites.has no longer parses a URL for every card that
isn't a favorite by key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 09:47:43 +00:00
Simon
b4031b5d0e Add a grid smoke suite, starting with content bleed
The repo has no tests, and the card pool that follows is exactly the kind of
change that breaks quietly: a release path that forgets to clear something
shows one video's title, thumbnail or heart on another video's card. So the
check that matters is that every mounted card renders the video its own
data-video-id names -- asserted after scrolling down and back, which is when
cards get reused.

Passes against the current build, where nothing is recycled yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-09 09:12:54 +00:00
21 changed files with 5022 additions and 438 deletions

View File

@@ -450,6 +450,104 @@ def resolve_video():
return jsonify(view_of(info))
# The picture a page says it has, in the order worth trusting: the card the
# page wants shared, then the one it declares to search engines, then the still
# its own player shows before playing.
_POSTER_META_RE = re.compile(
r'''<meta[^>]+(?:property|name|itemprop)\s*=\s*["']?'''
r'''(og:image(?::url)?|twitter:image(?::src)?|thumbnailUrl)["']?[^>]*>''', re.I)
_POSTER_CONTENT_RE = re.compile(r'''content\s*=\s*["']([^"']+)["']''', re.I)
_POSTER_VIDEO_RE = re.compile(r'''<video[^>]+poster\s*=\s*["']([^"']+)["']''', re.I)
# Everything worth having is in the head; a post page's comment section is not.
_POSTER_SCAN_BYTES = 256 * 1024
POSTER_CACHE_TTL = 600
_poster_cache = {}
_poster_cache_lock = threading.Lock()
def _poster_from_html(html, base_url):
"""The first usable image URL declared by `html`, absolute, or None."""
candidates = []
for match in _POSTER_META_RE.finditer(html):
content = _POSTER_CONTENT_RE.search(match.group(0))
if content:
candidates.append((match.group(1).lower(), content.group(1)))
ranked = []
for key in ('og:image', 'og:image:url', 'twitter:image', 'twitter:image:src', 'thumbnailurl'):
ranked += [url for name, url in candidates if name == key]
video_poster = _POSTER_VIDEO_RE.search(html)
if video_poster:
ranked.append(video_poster.group(1))
for url in ranked:
absolute = urljoin(base_url, url.strip())
parsed = urllib.parse.urlparse(absolute)
if parsed.scheme in ('http', 'https') and parsed.netloc:
return absolute
return None
@app.route('/api/poster', methods=['GET'])
def page_poster():
"""Ask a video's own page what picture it shows.
A listing's thumbnail can be dead on arrival. sxyprn hands out a still it
derives from the video (`.../vid/<token>/.../full.jpg`) which, for a post
made minutes ago, the CDN has not generated -- while the post page itself
shows one that works (`.../img/<other token>/.../0.webp`). Nothing on the
client can guess the second from the first: each path carries its own
signature. So when a thumbnail has failed every way we know to ask for it,
the page it came from gets asked what it shows.
Cheap to be wrong about and expensive to repeat, so answers -- including
"nothing" -- are cached for a few minutes."""
page_url = request.args.get('url')
if not page_url:
return jsonify({"error": "No URL provided"}), 400
parsed = urllib.parse.urlparse(page_url)
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
return jsonify({"error": "Invalid target URL"}), 400
now = time.time()
with _poster_cache_lock:
for key in [k for k, v in _poster_cache.items() if v[0] <= now]:
_poster_cache.pop(key, None)
hit = _poster_cache.get(page_url)
if hit:
return jsonify({"thumb": hit[1]})
thumb = None
sess = _borrow_session()
try:
# Streamed, and only the head of it: a page URL that turns out to
# redirect to the video itself (the Hot Tub proxy does exactly that for
# some channels) must cost one buffer, not a whole download.
resp = sess.get(page_url, headers={'Referer': page_url}, timeout=15,
allow_redirects=True, stream=True)
try:
content_type = (resp.headers.get('Content-Type') or '').lower()
if resp.status_code < 400 and ('html' in content_type or not content_type):
head = b''
for chunk in resp.iter_content(32 * 1024):
head += chunk
if len(head) >= _POSTER_SCAN_BYTES:
break
thumb = _poster_from_html(
head.decode(resp.encoding or 'utf-8', errors='replace'),
resp.url or page_url)
finally:
resp.close()
except Exception:
_discard_session(sess)
sess = None
finally:
if sess is not None:
_return_session(sess)
with _poster_cache_lock:
_poster_cache[page_url] = (now + POSTER_CACHE_TTL, thumb)
return jsonify({"thumb": thumb})
@app.route('/api/image', methods=['GET', 'HEAD'])
def image_proxy():
image_url = request.args.get('url')

View File

@@ -1,5 +1,11 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
/* A card keeps its optional parts (live badge, uploader, duration, tags) at all
times and hides the ones this video doesn't need, so any pooled card fits any
video -- see bindCard. Several of those carry their own `display`, which beats
the UA rule for [hidden], so say it once and mean it. */
[hidden] { display: none !important; }
:root {
/* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */
--bg-primary: #14110d;
@@ -1041,6 +1047,12 @@ body.favorites-view-open .favorites-empty {
precomputed (top,left). */
}
/* Each card's layout and paint stay its own business, so inserting one during
a scroll cannot make the browser reconsider the rest of the grid. */
.video-card {
contain: layout paint;
}
.video-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 28px var(--shadow);
@@ -1312,7 +1324,7 @@ body.theme-light .video-menu-btn {
}
/* Live streams can't be seeked, so hide the feed scrubber. */
.feed-slide.is-live .feed-timeline {
.feed-pane.is-live .feed-timeline {
display: none;
}
@@ -1381,18 +1393,40 @@ body.theme-light .favorite-btn {
font-family: var(--font-display);
font-size: 18px;
margin: 0 0 16px 0;
/* Clear of the copy/close buttons parked in the corner. */
padding-right: 140px;
}
.info-close {
/* Copy and close sit together in the card's top corner, above the title. */
.info-actions {
position: absolute;
right: 16px;
top: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.info-copy {
width: auto;
padding: 6px 12px;
font-size: 12px;
white-space: nowrap;
}
.info-copy.is-copied {
border-color: var(--border-hover);
color: var(--accent);
}
.info-close {
border: none;
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 50%;
width: 32px;
height: 32px;
flex-shrink: 0;
cursor: pointer;
}
@@ -1494,6 +1528,22 @@ body.theme-light .favorite-btn {
animation: cp-open 0.22s ease;
}
/* Loading before it is shown: laid out, so the media element loads as it
normally would (a display:none video doesn't, on iOS), but invisible and
transparent to the pointer, so the grid underneath stays the page the
viewer is on. */
.custom-player.is-preloading,
/* The HUD's own children opt back into pointer events, so they have to be
told as well -- an invisible close button must not eat a card's click. */
.custom-player.is-preloading * {
pointer-events: none;
}
.custom-player.is-preloading {
display: block;
opacity: 0;
}
@keyframes cp-open {
from { opacity: 0; }
to { opacity: 1; }
@@ -2146,6 +2196,9 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
flex: none;
}
/* One screenful. It holds the pane tree rather than a video directly: with
several panes, a step shows that many videos at once and one swipe advances
the whole set. */
.feed-slide {
position: relative;
width: 100%;
@@ -2153,6 +2206,34 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
height: 100dvh;
scroll-snap-align: start;
scroll-snap-stop: always;
overflow: hidden;
background: #000;
}
/* A branch of the tree: two children sharing the space, side by side or
stacked. Nesting these is what makes any arrangement reachable. */
.feed-split {
display: flex;
width: 100%;
height: 100%;
gap: 2px;
}
.feed-split.is-row { flex-direction: row; }
.feed-split.is-col { flex-direction: column; }
/* min-* is what stops a flex child refusing to shrink below its content. */
.feed-split > .feed-pane,
.feed-split > .feed-split {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.feed-pane {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
@@ -2160,6 +2241,45 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
background: #000;
}
.feed-pane-tools {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
/* Above the video, below the right-hand controls it sits beside. */
z-index: 5;
}
.feed-pane-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba(244, 232, 212, 0.18);
background: rgba(0, 0, 0, 0.45);
color: var(--text-primary);
font-size: 14px;
line-height: 1;
cursor: pointer;
backdrop-filter: blur(6px);
transition: background 0.2s ease, border-color 0.2s ease;
}
.feed-pane-btn:hover {
background: rgba(0, 0, 0, 0.7);
border-color: var(--border-hover);
}
.feed-pane-mute.is-muted {
border-color: var(--accent);
}
/* The HUD's idle fade takes the panel tools with it, like every other control. */
.feed-hud-idle .feed-pane-tools {
opacity: 0;
transition: opacity 0.4s ease;
}
.feed-poster,
.feed-video {
position: absolute;
@@ -2173,7 +2293,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
transition: opacity 0.2s ease;
}
.feed-slide.is-loaded .feed-poster {
.feed-pane.is-loaded .feed-poster {
opacity: 0;
}
@@ -2298,6 +2418,22 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
}
/* Per-slide favorite (heart) button on the feed HUD right rail. */
/* The right-hand controls stack upwards from just above the caption, rather
than sitting at fixed distances from the bottom. A pane can be a third of
the viewport tall, and viewport-scale offsets put these outside it -- clipped
by the pane's own overflow and unreachable. */
.feed-pane .feed-fav-btn,
.feed-pane .feed-pip-btn,
.feed-pane .feed-format-btn {
position: absolute;
right: 12px;
bottom: auto;
top: 56px;
}
.feed-pane .feed-pip-btn { top: 100px; }
.feed-pane .feed-format-btn { top: 144px; }
.feed-fav-btn {
position: absolute;
top: auto;
@@ -2366,13 +2502,13 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
interactive (pointer-events untouched) so the buttons keep working while
invisible; any pointer/scroll activity reveals them again (see App.feed). */
body.feed-hud-idle .feed-info,
body.feed-hud-idle .feed-timeline,
body.feed-hud-idle .feed-mute-btn,
body.feed-hud-idle .feed-fav-btn,
body.feed-hud-idle .feed-pip-btn,
body.feed-hud-idle .feed-format-btn,
body.feed-hud-idle .mode-toggle-btn {
.feed-hud-idle .feed-info,
.feed-hud-idle .feed-timeline,
.feed-hud-idle .feed-mute-btn,
.feed-hud-idle .feed-fav-btn,
.feed-hud-idle .feed-pip-btn,
.feed-hud-idle .feed-format-btn,
.feed-hud-idle .mode-toggle-btn {
opacity: 0;
}
@@ -2606,6 +2742,257 @@ body.theme-light .video-card img:not(.is-loaded) {
flex-shrink: 0;
}
/* --- Channel picker ---------------------------------------------------- */
/* The trigger in the menu drawer: what's being read now, favicon and all. */
.channel-trigger {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 14px;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.channel-trigger:hover,
.channel-trigger:focus-visible {
border-color: var(--border-hover);
outline: none;
}
.channel-trigger-text {
flex: 1;
min-width: 0;
}
.channel-trigger-name,
.channel-trigger-note {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-trigger-note {
font-size: 12px;
color: var(--text-secondary);
text-transform: none;
letter-spacing: 0;
}
.channel-trigger-caret {
flex-shrink: 0;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--text-secondary);
}
/* A favicon, with the channel's initial behind it for the ones that never
arrive -- a row is never a name beside an empty square. */
.channel-mark {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(201, 165, 103, 0.14);
border: 1px solid var(--border);
overflow: hidden;
}
.channel-mark::before {
content: attr(data-letter);
font-family: var(--font-display);
font-size: 14px;
line-height: 1;
color: var(--accent);
}
.channel-favicon {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
padding: 3px;
background: inherit;
}
.channel-picker {
position: fixed;
inset: 0;
z-index: 4200;
display: none;
align-items: flex-start;
justify-content: center;
padding: 8vh 12px 12px 12px;
background: rgba(10, 8, 4, 0.55);
backdrop-filter: blur(6px);
}
.channel-picker.open { display: flex; }
.channel-picker-box {
display: flex;
flex-direction: column;
width: min(560px, 100%);
max-height: min(74vh, 700px);
/* Solid, not the usual translucent panel: the section headings stick over
the rows as they scroll, and a see-through heading is unreadable. */
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 30px 70px var(--shadow);
overflow: hidden;
animation: card-rise 0.2s ease both;
}
.channel-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 10px 18px;
}
.channel-picker-head h3 {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
}
.channel-search {
margin: 0 18px 12px 18px;
padding: 11px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 14px;
}
.channel-search::placeholder { color: var(--text-secondary); }
.channel-search:focus { outline: none; border-color: var(--border-hover); }
.channel-picker-list {
flex: 1;
overflow-y: auto;
padding: 0 10px 10px 10px;
overscroll-behavior: contain;
}
.channel-section {
position: sticky;
top: 0;
z-index: 1;
padding: 10px 8px 6px 8px;
background: var(--bg-primary);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
}
.channel-row {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text-primary);
font-family: var(--font-body);
text-align: left;
cursor: pointer;
}
.channel-row.is-active { background: var(--bg-tertiary); }
.channel-row.is-current {
border-color: var(--border-hover);
background: rgba(201, 165, 103, 0.10);
}
.channel-row-text {
flex: 1;
min-width: 0;
}
.channel-row-name {
display: block;
font-size: 14px;
}
.channel-row-note {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: 2px;
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary);
}
.channel-row-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
}
.channel-tag {
padding: 2px 7px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 10px;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.channel-row-flag {
flex-shrink: 0;
align-self: center;
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.channel-picker-empty {
padding: 8px 20px 20px 20px;
color: var(--text-secondary);
font-size: 13px;
}
@media (max-width: 720px) {
.channel-picker { padding: 0; }
.channel-picker-box {
width: 100%;
max-height: 100%;
height: 100%;
border: none;
border-radius: 0;
}
}
/* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */
.feed-title { font-family: var(--font-display); }

View File

@@ -58,8 +58,17 @@
<select id="source-select"></select>
</div>
<div class="setting-item">
<label for="channel-select">Channel</label>
<select id="channel-select"></select>
<label for="channel-picker-btn">Channel</label>
<button id="channel-picker-btn" class="channel-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
<span class="channel-mark" id="channel-trigger-mark">
<img class="channel-favicon" id="channel-trigger-icon" alt="" decoding="async">
</span>
<span class="channel-trigger-text">
<span class="channel-trigger-name" id="channel-trigger-name">No channel</span>
<span class="channel-trigger-note" id="channel-trigger-note"></span>
</span>
<span class="channel-trigger-caret" aria-hidden="true"></span>
</button>
</div>
</div>
@@ -175,7 +184,10 @@
<div id="info-modal" class="info-modal" aria-hidden="true">
<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>
<div class="info-actions">
<button id="info-copy" class="btn-secondary info-copy" type="button">Copy JSON</button>
<button id="info-close" class="info-close" type="button" aria-label="Close"></button>
</div>
<h3 id="info-title">Video Info</h3>
<div id="info-list" class="info-list"></div>
<div id="info-empty" class="info-empty">No additional info available.</div>
@@ -194,6 +206,19 @@
<button id="back-to-top" class="back-to-top" type="button" title="Back to top" aria-label="Back to top"></button>
<div id="channel-picker" class="channel-picker" aria-hidden="true">
<div class="channel-picker-box" role="dialog" aria-modal="true" aria-labelledby="channel-picker-title">
<div class="channel-picker-head">
<h3 id="channel-picker-title">Channel</h3>
<button id="channel-picker-close" class="close-btn" type="button" aria-label="Close"></button>
</div>
<input id="channel-search" class="channel-search" type="text" placeholder="Search channels…"
autocomplete="off" spellcheck="false" aria-controls="channel-picker-list">
<div id="channel-picker-list" class="channel-picker-list" role="listbox" aria-label="Channels"></div>
<div id="channel-picker-empty" class="channel-picker-empty">No channel matches that.</div>
</div>
</div>
<div id="command-palette" class="command-palette" aria-hidden="true">
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
<input id="cmdk-input" class="cmdk-input" type="text" placeholder="Type a command or search… (⌘K)" autocomplete="off" spellcheck="false">

View File

@@ -194,36 +194,138 @@ App.customPlayer = App.customPlayer || {};
// tab/app is backgrounded while a video is playing (Safari does this
// natively for inline video; Chrome/Android need an explicit call).
// -----------------------------------------------------------------
App.customPlayer.supportsPiP = function() {
return !!(document.pictureInPictureEnabled);
// iOS has picture-in-picture, but not this API: Safari on iPhone and iPad
// never implemented requestPictureInPicture, and exposes WebKit's older
// presentation-mode switch instead. document.pictureInPictureEnabled is
// undefined there, so every check below it used to answer "no" and the
// button was hidden on the one platform where people most want it.
//
// The difference is confined here. enterPiP/exitPiP/pipElement speak for
// both, and bindPiPEvents re-fires WebKit's non-bubbling
// webkitpresentationmodechanged as the standard enter/leave events, so the
// delegated listeners elsewhere work unchanged.
const WEBKIT_PIP = 'picture-in-picture';
let webkitPipElement = null;
const standardPiP = () => !!document.pictureInPictureEnabled;
const webkitPiP = (video) => !!(video && typeof video.webkitSetPresentationMode === 'function');
// No element to ask about: does this browser have either API at all?
let webkitProbe = null;
const webkitAvailable = function() {
if (webkitProbe === null) {
webkitProbe = typeof HTMLVideoElement !== 'undefined' &&
(typeof HTMLVideoElement.prototype.webkitSetPresentationMode === 'function' ||
webkitPiP(document.createElement('video')));
}
return webkitProbe;
};
App.customPlayer.togglePiP = async function(video) {
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
try {
if (document.pictureInPictureElement === video) {
await document.exitPictureInPicture();
} else {
// Deliberately the method's presence rather than
// video.webkitSupportsPresentationMode(): that answers false until a video
// track is loaded, and feed videos are preload="none" until they go active
// -- it would hide the button on exactly the videos about to be able to
// use it.
App.customPlayer.supportsPiP = function() {
return standardPiP() || webkitAvailable();
};
App.customPlayer.pipElement = function() {
return document.pictureInPictureElement || webkitPipElement || null;
};
App.customPlayer.enterPiP = async function(video) {
if (!video) return false;
if (standardPiP()) {
if (video.disablePictureInPicture) return false;
try {
await video.requestPictureInPicture();
return true;
} catch (err) {
return false;
}
}
if (!webkitPiP(video)) return false;
try {
// Synchronous, and it needs the user gesture that got us here.
video.webkitSetPresentationMode(WEBKIT_PIP);
return true;
} catch (err) {
return false;
}
};
App.customPlayer.exitPiP = async function() {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture().catch(() => {});
return;
}
const video = webkitPipElement;
if (!webkitPiP(video)) return;
try { video.webkitSetPresentationMode('inline'); } catch (err) { /* already gone */ }
};
App.customPlayer.bindPiPEvents = function(video) {
if (standardPiP() || !webkitPiP(video)) return function destroy() {};
// The same event announces fullscreen and inline, so only a real change
// in picture-in-picture-ness is worth reporting.
let wasPip = video.webkitPresentationMode === WEBKIT_PIP;
const onChange = function() {
const isPip = video.webkitPresentationMode === WEBKIT_PIP;
if (isPip === wasPip) return;
wasPip = isPip;
if (isPip) webkitPipElement = video;
else if (webkitPipElement === video) webkitPipElement = null;
video.dispatchEvent(new CustomEvent(
isPip ? 'enterpictureinpicture' : 'leavepictureinpicture', { bubbles: true }));
};
video.addEventListener('webkitpresentationmodechanged', onChange);
return function destroy() {
video.removeEventListener('webkitpresentationmodechanged', onChange);
if (webkitPipElement === video) webkitPipElement = null;
};
};
App.customPlayer.togglePiP = async function(video) {
if (!video) return false;
if (App.customPlayer.pipElement() === video) {
await App.customPlayer.exitPiP();
return true;
}
return App.customPlayer.enterPiP(video);
};
// Asking for picture-in-picture the moment a tab is hidden is a request
// with no user gesture behind it, and browsers refuse those -- which is why
// the imperative call below fails silently. `autoPictureInPicture` is the
// declarative form made for exactly this: the browser is told in advance
// which video should follow the reader out, and does it itself. Safari
// honours it outright; Chrome honours it for installed apps. The call is
// kept as a fallback for anywhere the flag is ignored but the request is
// allowed.
App.customPlayer.setAutoPiP = function(video, on) {
if (!video) return;
try { video.autoPictureInPicture = !!on; } catch (err) { /* unsupported */ }
if (on) video.setAttribute('autopictureinpicture', '');
else video.removeAttribute('autopictureinpicture');
};
App.customPlayer.bindAutoPiP = function(video) {
if (!video) return function destroy() {};
App.customPlayer.setAutoPiP(video, true);
const unbindEvents = App.customPlayer.bindPiPEvents(video);
const trigger = () => {
if (document.visibilityState !== 'hidden') return;
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
if (document.pictureInPictureElement) return;
if (!App.customPlayer.supportsPiP() || video.disablePictureInPicture) return;
if (App.customPlayer.pipElement()) return;
if (video.paused || video.ended) return;
video.requestPictureInPicture().catch(() => {});
App.customPlayer.enterPiP(video);
};
document.addEventListener('visibilitychange', trigger);
window.addEventListener('pagehide', trigger);
return function destroy() {
App.customPlayer.setAutoPiP(video, false);
unbindEvents();
document.removeEventListener('visibilitychange', trigger);
window.removeEventListener('pagehide', trigger);
};

View File

@@ -60,6 +60,15 @@ App.enhance = App.enhance || {};
if (!grid || !fineHover) return;
let dwellTimer = null;
let activeCard = null;
// The card element alone doesn't identify what is being previewed: the
// grid pools its cards, so the same element can come back showing a
// different video (via relayout or a new search, neither of which
// scrolls, so clearPreview never runs). Remembering the video too keeps
// the "already previewing this" check honest.
let activeVideo = null;
const isActive = (card) => card === activeCard &&
(!App.videos.getVideoForCard || App.videos.getVideoForCard(card) === activeVideo);
const clearPreview = () => {
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
@@ -68,6 +77,7 @@ App.enhance = App.enhance || {};
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
activeCard.classList.remove('is-previewing');
activeCard = null;
activeVideo = null;
}
};
@@ -105,10 +115,14 @@ App.enhance = App.enhance || {};
grid.addEventListener('pointerover', (e) => {
const card = e.target.closest('.video-card');
if (!card || card === activeCard) return;
if (!card || isActive(card)) return;
clearPreview();
activeCard = card;
dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === card) startPreview(card); }, 600);
activeVideo = App.videos.getVideoForCard ? App.videos.getVideoForCard(card) : null;
dwellTimer = setTimeout(() => {
dwellTimer = null;
if (isActive(card)) startPreview(card);
}, 600);
});
grid.addEventListener('pointerout', (e) => {
const card = e.target.closest('.video-card');
@@ -182,11 +196,19 @@ App.enhance = App.enhance || {};
out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } });
});
}
const channelSelect = document.getElementById('channel-select');
if (channelSelect) {
Array.from(channelSelect.options).forEach((opt) => {
if (opt.value === channelSelect.value) return;
out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } });
// The channel list is the picker's (App.ui.channels), not a
// <select>'s -- same entries, drawn as palette rows.
if (App.ui && App.ui.channels) {
const current = App.storage && App.storage.getSession ?
App.storage.getSession() : null;
const currentId = (current && current.channel) ? current.channel.id : '';
App.ui.channels.entries().forEach((entry) => {
if (entry.id === currentId) return;
out.push({
label: entry.label,
hint: entry.group || 'Channel',
run: () => App.ui.channels.choose(entry.id)
});
});
}
return out;

View File

@@ -224,6 +224,10 @@ App.favorites = App.favorites || {};
const index = identities();
const key = App.favorites.getKey(video);
if (key && index.keys.has(key)) return true;
// Normalising a URL means parsing one, which is the expensive half of
// this and runs for every card that isn't a favorite by key. With
// nothing stored to match against, there is nothing to parse it for.
if (!index.urls.size) return false;
const meta = (video && video.meta) || video || {};
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
return !!(urlKey && index.urls.has(urlKey));
@@ -490,7 +494,7 @@ App.favorites = App.favorites || {};
`;
const thumb = card.querySelector('img');
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
App.videos.attachThumbnail(thumb, item.thumb);
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item));
}
card.onclick = () => {
if (card.classList.contains('is-loading')) return;

File diff suppressed because it is too large Load Diff

View File

@@ -23,12 +23,25 @@ App.player = App.player || {};
historyPushed: false,
idleTimer: null,
originEl: null,
originToken: null, // stamp proving originEl is still the card we opened
hudHovered: false, // mouse resting on the controls (desktop)
activeUrl: '', // media URL actually playing, for the format menu's tick
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
fetchAbort: null // aborts the current attempt's own requests
fetchAbort: null, // aborts the current attempt's own requests
pending: false, // loading off-screen, not shown yet (see reveal)
revealTimer: null,
mutedBeforeReveal: null
};
// A session exists: either on screen, or still loading in the background
// before it gets there. Everything that used to ask "is the player open?"
// means this -- a pending session owns the same history entry, the same
// card spinner and the same in-flight requests as a shown one.
function isActive() {
return !!(cp.container && (cp.pending || cp.container.classList.contains('open')));
}
App.player.isActive = isActive;
// Stops everything the current attempt has in flight. The token guards keep
// stale *callbacks* from acting, but they don't stop the requests those
// callbacks were waiting on: hls.js goes on pulling segments through the
@@ -59,6 +72,28 @@ App.player = App.player || {};
}
}
// The card the player was opened from is owned by the grid, which pools and
// reuses its cards (see resetCard in videos.js). By the time the player lets
// go, that element may be showing a different video -- so it is stamped at
// open, and every later touch checks the stamp still matches. A recycled
// card has had it wiped, and simply stops answering.
let originSeq = 0;
const claimOrigin = function(el) {
if (!el) return null;
const token = String(++originSeq);
el.dataset.playerToken = token;
return token;
};
const withOrigin = function(el, token, fn) {
// `token` must be truthy in its own right: dataset yields undefined for
// a missing attribute, so without this an unstamped token would match
// every card that has no stamp -- including one the grid has recycled,
// which is precisely the case this guard exists to catch.
if (el && token && el.dataset.playerToken === token) fn(el);
};
const addCleanup = (fn) => cp.cleanups.push(fn);
const runCleanups = () => {
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
@@ -542,7 +577,10 @@ App.player = App.player || {};
// ---------------------------------------------------------------------
function bindKeyboard(video) {
const onKeyDown = (event) => {
if (!cp.container || !cp.container.classList.contains('open')) return;
if (!isActive()) return;
// Nothing else is worth doing to a video nobody can see yet, but
// changing your mind about it is.
if (cp.pending && event.key !== 'Escape') return;
switch (event.key) {
case ' ':
case 'k':
@@ -592,7 +630,7 @@ App.player = App.player || {};
addCleanup(() => closeBtn.removeEventListener('click', onClick));
}
const onPopState = () => {
if (cp.container && cp.container.classList.contains('open')) {
if (isActive()) {
cp.historyPushed = false; // the pushed state was just consumed by the browser
App.player.close({ fromPopState: true });
}
@@ -634,7 +672,64 @@ App.player = App.player || {};
if (spinner) spinner.classList.toggle('is-visible', show);
}
// ---------------------------------------------------------------------
// Going on screen
//
// Opening a video used to put an empty black player up immediately and
// spin at the viewer until the first frame arrived -- which, between
// resolving the formats and the first bytes of a stream, is routinely a
// couple of seconds of nothing. So the session now starts off-screen:
// the card that was clicked keeps its own spinner, the page stays where
// it was, and the player appears only once the video has real data to
// show. Failures reveal it too -- the error and its retry live inside the
// player -- and so does the timeout below, because a stream that is merely
// slow is better watched from inside the player (which can be closed) than
// from a card that looks stuck.
// ---------------------------------------------------------------------
const REVEAL_TIMEOUT_MS = 5000;
function reveal() {
if (!cp.container) return;
if (cp.revealTimer) {
clearTimeout(cp.revealTimer);
cp.revealTimer = null;
}
cp.pending = false;
// The card has handed over, whether this is the first reveal or a
// second video opened over the top of the first.
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
if (cp.container.classList.contains('open')) return;
cp.container.classList.remove('is-preloading');
cp.container.classList.add('open');
cp.container.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
// Muted while it was loading out of sight; it is in sight now.
if (cp.mutedBeforeReveal !== null && cp.video) {
cp.video.muted = cp.mutedBeforeReveal;
cp.mutedBeforeReveal = null;
}
wakeHud();
}
// The first data is the cue: `loadeddata` means a frame can be drawn, and
// `playing` covers the sources that get there without one (audio-only, and
// anything whose first frame lands before the listener is attached).
function bindReveal(video) {
const onData = () => reveal();
video.addEventListener('loadeddata', onData);
video.addEventListener('playing', onData);
addCleanup(() => {
video.removeEventListener('loadeddata', onData);
video.removeEventListener('playing', onData);
});
if (video.readyState >= 2) reveal();
}
function showError(message, onRetry, sourceUrl) {
// Whatever went wrong, it says so in the player -- which the viewer
// can only read if the player is on screen.
reveal();
showBuffering(false);
const errorEl = q('.cp-error');
const textEl = q('.cp-error-text');
@@ -695,9 +790,10 @@ App.player = App.player || {};
// the wrong card loaded or (via the token guard below) never clear
// this card's spinner at all.
const originEl = (opts && opts.originEl) || null;
const originToken = originEl ? originEl.dataset.playerToken : null;
const sources = resolveSources(videoData);
const clearLoading = () => {
if (originEl) originEl.classList.remove('is-loading');
withOrigin(originEl, originToken, (el) => el.classList.remove('is-loading'));
};
const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || '';
@@ -830,7 +926,6 @@ App.player = App.player || {};
// Whichever candidate got this far is the one on screen -- not
// necessarily the one the ranking (or the viewer) asked for.
cp.activeUrl = resolved.url || '';
clearLoading();
hideError();
showBuffering(false);
if (resumeAt > 0) {
@@ -916,16 +1011,17 @@ App.player = App.player || {};
// single history.back() could never fully unwind. Also clears the
// abandoned session's own loading spinner, since its card would
// otherwise never hear about the takeover.
const reopening = !!(cp.container && cp.container.classList.contains('open'));
const reopening = isActive();
if (reopening) {
cp.attemptToken++;
cancelInFlight();
clearIdleTimer();
if (cp.originEl) cp.originEl.classList.remove('is-loading');
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
}
runCleanups();
cp.originEl = opts && opts.originEl ? opts.originEl : null;
cp.originToken = claimOrigin(cp.originEl);
if (cp.originEl) cp.originEl.classList.add('is-loading');
cp.container = buildContainer();
@@ -997,11 +1093,21 @@ App.player = App.player || {};
bindKeyboard(cp.video);
bindClose();
bindBufferingIndicator(cp.video);
bindReveal(cp.video);
cp.container.classList.add('open');
cp.container.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
wakeHud();
// Loading out of sight: laid out (so the media element behaves as it
// would on screen -- iOS in particular will not load a display:none
// video) but transparent and untouchable, so the page underneath is
// still the page the viewer is using.
if (!cp.container.classList.contains('open')) {
cp.pending = true;
cp.container.classList.add('is-preloading');
// Nothing should be heard from a player that isn't there yet.
cp.mutedBeforeReveal = cp.video.muted;
cp.video.muted = true;
if (cp.revealTimer) clearTimeout(cp.revealTimer);
cp.revealTimer = setTimeout(reveal, REVEAL_TIMEOUT_MS);
}
// Already-resolved sources start immediately; unresolved ones start from
// the ensureFormats() callback above (the spinner is already up).
@@ -1009,7 +1115,7 @@ App.player = App.player || {};
};
App.player.close = function(opts) {
if (!cp.container || !cp.container.classList.contains('open')) return;
if (!isActive()) return;
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
// Closing the player must also stop what it was fetching -- otherwise a
// proxied stream keeps being pulled, and the server keeps an upstream
@@ -1018,7 +1124,13 @@ App.player = App.player || {};
clearIdleTimer();
runCleanups();
cp.container.classList.remove('open', 'cp-hud-idle', 'is-live');
if (cp.revealTimer) {
clearTimeout(cp.revealTimer);
cp.revealTimer = null;
}
cp.pending = false;
cp.mutedBeforeReveal = null;
cp.container.classList.remove('open', 'is-preloading', 'cp-hud-idle', 'is-live');
cp.container.style.transform = '';
cp.container.style.opacity = '';
cp.container.setAttribute('aria-hidden', 'true');
@@ -1031,10 +1143,12 @@ App.player = App.player || {};
cp.historyPushed = false;
}
if (cp.originEl) {
cp.originEl.classList.remove('is-loading');
cp.originEl = null;
}
withOrigin(cp.originEl, cp.originToken, (el) => {
el.classList.remove('is-loading');
delete el.dataset.playerToken;
});
cp.originEl = null;
cp.originToken = null;
cp.data = null;
cp.source = null; // voids a still-pending format resolve for this open
cp.formatOverride = null;

View File

@@ -69,6 +69,62 @@ App.ui = App.ui || {};
// Which video the panel is currently showing, so a slow resolve that lands
// after the user moved on doesn't redraw someone else's panel.
let infoVideo = null;
// Exactly what the panel is showing, as one object -- what the copy button
// hands over. Kept beside the rendering rather than rebuilt on click, so
// the two can't disagree about what "this video" means.
let infoPayload = null;
// The clipboard proper needs a secure context, which a home-screen app on
// https has and a plain-http LAN address does not -- hence the old
// execCommand path behind it, which only works on a selection in the
// document.
const copyText = async function(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) { /* fall through to the old way */ }
}
const scratch = document.createElement('textarea');
scratch.value = text;
scratch.setAttribute('readonly', '');
// Off-screen but focusable: display:none or visibility:hidden would
// leave nothing to select, and a visible one would scroll the page.
scratch.style.position = 'fixed';
scratch.style.top = '-1000px';
scratch.style.opacity = '0';
document.body.appendChild(scratch);
try {
scratch.select();
return document.execCommand('copy');
} catch (err) {
return false;
} finally {
scratch.remove();
}
};
let copyResetTimer = null;
App.ui.copyInfoJson = async function() {
const button = document.getElementById('info-copy');
if (!infoPayload) return false;
const copied = await copyText(JSON.stringify(infoPayload, null, 2));
if (!copied) {
App.ui.showError('Could not copy to the clipboard.');
return false;
}
if (button) {
button.classList.add('is-copied');
button.textContent = 'Copied';
if (copyResetTimer) clearTimeout(copyResetTimer);
copyResetTimer = setTimeout(() => {
button.classList.remove('is-copied');
button.textContent = 'Copy JSON';
}, 1600);
}
return true;
};
const appendInfoHeading = function(list, label) {
const heading = document.createElement('div');
@@ -131,12 +187,16 @@ App.ui = App.ui || {};
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
// `meta` gets its own section below rather than a row of JSON.
const own = Object.assign({}, item);
delete own.meta;
const section = opts.info ? 'extractor' : 'resolved';
infoPayload = Object.assign({}, own);
if (resolved && typeof resolved === 'object') infoPayload[section] = resolved;
let rows = 0;
if (list) {
list.innerHTML = "";
// `meta` gets its own section below rather than a row of JSON.
const own = Object.assign({}, item);
delete own.meta;
rows += appendInfoRows(list, own);
if (resolved && typeof resolved === 'object') {
@@ -166,6 +226,13 @@ App.ui = App.ui || {};
// nothing (or at a spinner) while it does.
App.ui.openInfo = function(video) {
infoVideo = video;
// A fresh panel, so the button stops saying it copied the last one.
const copyBtn = document.getElementById('info-copy');
if (copyBtn) {
if (copyResetTimer) clearTimeout(copyResetTimer);
copyBtn.classList.remove('is-copied');
copyBtn.textContent = 'Copy JSON';
}
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
App.ui.showInfo(video, { pending: canResolve });
if (!canResolve) return;
@@ -179,6 +246,7 @@ App.ui = App.ui || {};
const modal = document.getElementById('info-modal');
if (!modal) return;
infoVideo = null;
infoPayload = null;
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
};
@@ -229,12 +297,356 @@ App.ui = App.ui || {};
}
};
// ---------------------------------------------------------------------
// Channel picker
//
// A <select> of ninety channels tells the reader almost nothing: a wall of
// bare names, nothing to say which site a name belongs to or what it
// carries, and no way to look for one. The server sends far more than the
// name -- a favicon, a description, tags, whether the channel still says
// "work in progress" -- so the picker shows that, and lets the reader type.
//
// The <select> was also where the command palette read its channel actions
// from, so the list lives here now and the palette asks for it.
// ---------------------------------------------------------------------
App.ui.channels = (function() {
// Sections in display order: a group, then whatever the server didn't
// put in one. Rebuilt whenever the menu renders, which is whenever the
// server, its status, or the selection changes.
let sections = [];
let matched = []; // the rows the current search leaves on screen
let activeIndex = 0;
let bound = false;
const el = (id) => document.getElementById(id);
const activeServerData = function() {
const session = App.storage.getSession();
if (!session) return null;
const entry = App.storage.getServerEntries()
.find((candidate) => candidate.url === session.server);
return (entry && entry.data) || null;
};
// Everything about a channel worth searching, in one string: a reader
// typing "jav", "leaks" or the site's own name should all land.
const haystack = function(parts) {
return parts.filter(Boolean).join(' ').toLowerCase();
};
const channelRow = function(channel, groupTitle) {
const tags = Array.isArray(channel.tags) ? channel.tags : [];
return {
id: channel.id,
name: channel.name || channel.id,
note: channel.description || '',
favicon: channel.favicon || '',
tags: tags.slice(0, 3),
// "work in progress" is the server's own word for a channel
// that may not answer; worth saying before it's picked.
flag: channel.status && channel.status !== 'active' ? channel.status :
(channel.premium ? 'premium' : ''),
group: groupTitle || '',
search: haystack([channel.name, channel.id, channel.description,
tags.join(' '), groupTitle])
};
};
const build = function() {
const data = activeServerData();
const channels = (data && Array.isArray(data.channels)) ? data.channels : [];
const groups = (data && Array.isArray(data.channelGroups)) ? data.channelGroups : [];
const byId = new Map(channels.map((channel) => [channel.id, channel]));
const grouped = new Set();
sections = [];
groups.forEach((group) => {
const ids = (Array.isArray(group.channelIds) ? group.channelIds : [])
.filter((id) => byId.has(id));
if (!ids.length) return;
ids.forEach((id) => grouped.add(id));
const title = group.title || group.id;
sections.push({
title: title,
rows: [{
id: `group:${group.id}`,
name: `All ${title}`,
// Every group row would otherwise be an "A" for "All".
mark: title,
note: `Every channel in ${title}, interleaved.`,
favicon: '',
tags: [],
flag: ids.length === 1 ? '1 channel' : `${ids.length} channels`,
group: title,
search: haystack(['all', title, group.id])
}].concat(ids.map((id) => channelRow(byId.get(id), title)))
});
});
const ungrouped = channels
.filter((channel) => !grouped.has(channel.id))
.sort((a, b) => (a.name || a.id || '').toLowerCase()
.localeCompare((b.name || b.id || '').toLowerCase()));
if (ungrouped.length) {
sections.push({
title: sections.length ? 'Everything else' : 'Channels',
rows: ungrouped.map((channel) => channelRow(channel, ''))
});
}
};
// The letter behind a favicon that hasn't arrived (or never will), so a
// row is never a name next to an empty square.
const markFor = function(row) {
const mark = document.createElement('span');
mark.className = 'channel-mark';
mark.dataset.letter = (row.mark || row.name || '?').trim().charAt(0).toUpperCase();
if (row.favicon) {
const img = document.createElement('img');
img.className = 'channel-favicon';
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
mark.appendChild(img);
// The same route race, proxy fallback and retries every other
// remote picture in the app goes through.
App.videos.attachThumbnail(img, row.favicon);
}
return mark;
};
const rowButton = function(row, currentId) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'channel-row' + (row.id === currentId ? ' is-current' : '');
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', row.id === currentId ? 'true' : 'false');
button.dataset.channelId = row.id;
button.appendChild(markFor(row));
const text = document.createElement('span');
text.className = 'channel-row-text';
const name = document.createElement('span');
name.className = 'channel-row-name';
name.textContent = row.name;
text.appendChild(name);
if (row.note) {
const note = document.createElement('span');
note.className = 'channel-row-note';
note.textContent = row.note;
text.appendChild(note);
}
if (row.tags.length) {
const tags = document.createElement('span');
tags.className = 'channel-row-tags';
row.tags.forEach((tag) => {
const chip = document.createElement('span');
chip.className = 'channel-tag';
chip.textContent = tag;
tags.appendChild(chip);
});
text.appendChild(tags);
}
button.appendChild(text);
if (row.flag) {
const flag = document.createElement('span');
flag.className = 'channel-row-flag';
flag.textContent = row.flag;
button.appendChild(flag);
}
return button;
};
const render = function() {
const list = el('channel-picker-list');
const empty = el('channel-picker-empty');
const search = el('channel-search');
if (!list) return;
const query = (search ? search.value : '').trim().toLowerCase();
const session = App.storage.getSession();
const currentId = (session && session.channel) ? session.channel.id : '';
list.innerHTML = '';
matched = [];
sections.forEach((section) => {
const rows = query
? section.rows.filter((row) => row.search.includes(query))
: section.rows;
if (!rows.length) return;
const heading = document.createElement('div');
heading.className = 'channel-section';
heading.textContent = section.title;
list.appendChild(heading);
rows.forEach((row) => {
const button = rowButton(row, currentId);
const index = matched.length;
button.addEventListener('click', () => App.ui.channels.choose(row.id));
button.addEventListener('pointermove', () => setActive(index));
list.appendChild(button);
matched.push(button);
});
});
if (empty) empty.hidden = matched.length > 0;
// A shorter list under an unchanged scroll position hides its own
// first hits, so every search starts back at the top.
if (query) list.scrollTop = 0;
// A search starts on its first hit; an unsearched list starts on the
// channel already being read, which is also what gets scrolled to.
const current = matched.findIndex((button) => button.classList.contains('is-current'));
setActive(query || current < 0 ? 0 : current, !query);
};
const setActive = function(index, scroll) {
if (!matched.length) { activeIndex = 0; return; }
activeIndex = Math.max(0, Math.min(index, matched.length - 1));
matched.forEach((button, i) => button.classList.toggle('is-active', i === activeIndex));
if (scroll && matched[activeIndex]) {
matched[activeIndex].scrollIntoView({ block: 'center' });
}
};
const step = function(delta) {
setActive(activeIndex + delta);
const button = matched[activeIndex];
if (button) button.scrollIntoView({ block: 'nearest' });
};
const open = function() {
const picker = el('channel-picker');
const search = el('channel-search');
const trigger = el('channel-picker-btn');
if (!picker) return;
build();
if (search) search.value = '';
// Shown before it is filled: scrolling the current channel into
// view can't work while the list is still display:none.
picker.classList.add('open');
picker.setAttribute('aria-hidden', 'false');
render();
if (trigger) trigger.setAttribute('aria-expanded', 'true');
// Typing is the point of the thing -- but not on a phone, where
// focusing the field throws up the keyboard over the list.
if (search && window.matchMedia('(min-width: 720px)').matches) {
requestAnimationFrame(() => search.focus());
}
};
const close = function() {
const picker = el('channel-picker');
const trigger = el('channel-picker-btn');
if (picker) {
picker.classList.remove('open');
picker.setAttribute('aria-hidden', 'true');
}
if (trigger) trigger.setAttribute('aria-expanded', 'false');
};
const bind = function() {
if (bound) return;
const picker = el('channel-picker');
const trigger = el('channel-picker-btn');
const search = el('channel-search');
const closeBtn = el('channel-picker-close');
if (!picker || !trigger) return;
bound = true;
trigger.addEventListener('click', open);
if (closeBtn) closeBtn.addEventListener('click', close);
picker.addEventListener('click', (event) => { if (event.target === picker) close(); });
if (search) {
search.addEventListener('input', render);
search.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown') { event.preventDefault(); step(1); }
else if (event.key === 'ArrowUp') { event.preventDefault(); step(-1); }
else if (event.key === 'Enter') {
event.preventDefault();
const button = matched[activeIndex];
if (button) button.click();
} else if (event.key === 'Escape') { event.preventDefault(); close(); }
});
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && picker.classList.contains('open')) close();
});
};
// The trigger says what is being read now, with the same favicon the
// list shows, so the menu answers the question without being opened.
const renderTrigger = function() {
const session = App.storage.getSession();
const channel = session && session.channel;
const name = el('channel-trigger-name');
const note = el('channel-trigger-note');
const mark = el('channel-trigger-mark');
const icon = el('channel-trigger-icon');
if (!name || !mark || !icon) return;
name.textContent = channel ? (channel.name || channel.id) : 'No channel';
mark.dataset.letter = (channel ? (channel.name || channel.id || '?') : '?')
.trim().charAt(0).toUpperCase();
if (note) {
note.textContent = channel
? (channel.isGroup ? 'Whole group' : (channel.description || ''))
: 'This source has no channels.';
}
App.videos.detachThumbnail(icon);
icon.removeAttribute('src');
icon.hidden = !(channel && channel.favicon);
if (channel && channel.favicon) App.videos.attachThumbnail(icon, channel.favicon);
};
return {
// Called by renderMenu: the picker follows whatever the menu is
// showing, and nothing else has to know it exists.
render: function() {
bind();
build();
renderTrigger();
if (el('channel-picker') && el('channel-picker').classList.contains('open')) {
render();
}
},
open: open,
close: close,
// The flat list the command palette offers alongside its own
// actions -- id and label only; it draws its own rows.
entries: function() {
if (!sections.length) build();
const out = [];
sections.forEach((section) => section.rows.forEach((row) => {
out.push({ id: row.id, label: row.name, group: section.title });
}));
return out;
},
choose: function(id) {
const session = App.storage.getSession();
const data = activeServerData();
const nextChannel = data ? App.session.resolveChannelById(data, id) : null;
if (!session || !nextChannel) return;
const serverPrefs = App.storage.getPreferences()[session.server] || {};
const savedOptions = serverPrefs.optionsByChannel ?
serverPrefs.optionsByChannel[nextChannel.id] : null;
const nextSession = {
server: session.server,
channel: nextChannel,
options: savedOptions ?
App.session.hydrateOptions(nextChannel, savedOptions) :
App.session.buildDefaultOptions(nextChannel)
};
App.storage.setSession(nextSession);
App.session.savePreference(nextSession);
close();
App.ui.renderMenu();
App.videos.resetAndReload();
}
};
})();
// Settings + menu rendering.
App.ui.renderMenu = function() {
const session = App.storage.getSession();
const serverEntries = App.storage.getServerEntries();
const sourceSelect = document.getElementById('source-select');
const channelSelect = document.getElementById('channel-select');
const filtersContainer = document.getElementById('filters-container');
const sourcesList = document.getElementById('sources-list');
const addSourceBtn = document.getElementById('add-source-btn');
@@ -242,7 +654,7 @@ App.ui = App.ui || {};
const reloadChannelBtn = document.getElementById('reload-channel-btn');
const favoritesToggle = document.getElementById('favorites-toggle');
if (!sourceSelect || !channelSelect || !filtersContainer) return;
if (!sourceSelect || !filtersContainer) return;
sourceSelect.innerHTML = "";
serverEntries.forEach((entry) => {
@@ -281,80 +693,7 @@ App.ui = App.ui || {};
App.videos.resetAndReload();
};
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
const activeServerData = activeServer && activeServer.data ? activeServer.data : null;
const availableChannels = activeServerData && activeServerData.channels ?
[...activeServerData.channels] :
[];
availableChannels.sort((a, b) => {
const nameA = (a.name || a.id || '').toLowerCase();
const nameB = (b.name || b.id || '').toLowerCase();
return nameA.localeCompare(nameB);
});
const channelGroups = activeServerData && Array.isArray(activeServerData.channelGroups) ?
activeServerData.channelGroups :
[];
channelSelect.innerHTML = "";
const groupedChannelIds = new Set();
channelGroups.forEach((group) => {
const channelIds = Array.isArray(group.channelIds) ?
group.channelIds.filter((id) => availableChannels.some((channel) => channel.id === id)) :
[];
if (channelIds.length === 0) return;
channelIds.forEach((id) => groupedChannelIds.add(id));
const optgroup = document.createElement('optgroup');
optgroup.label = group.title || group.id;
const groupOption = document.createElement('option');
groupOption.value = `group:${group.id}`;
groupOption.textContent = `All ${group.title || group.id}`;
optgroup.appendChild(groupOption);
channelIds.forEach((id) => {
const channel = availableChannels.find((ch) => ch.id === id);
const option = document.createElement('option');
option.value = channel.id;
option.textContent = channel.name || channel.id;
optgroup.appendChild(option);
});
channelSelect.appendChild(optgroup);
});
availableChannels
.filter((channel) => !groupedChannelIds.has(channel.id))
.forEach((channel) => {
const option = document.createElement('option');
option.value = channel.id;
option.textContent = channel.name || channel.id;
channelSelect.appendChild(option);
});
if (session && session.channel) {
channelSelect.value = session.channel.id;
}
channelSelect.onchange = () => {
const selectedId = channelSelect.value;
const nextChannel = activeServerData ? App.session.resolveChannelById(activeServerData, selectedId) : null;
const prefs = App.storage.getPreferences();
const serverPrefs = prefs[session.server] || {};
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
serverPrefs.optionsByChannel[nextChannel.id] :
null;
const nextSession = {
server: session.server,
channel: nextChannel,
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
};
App.storage.setSession(nextSession);
App.session.savePreference(nextSession);
App.ui.renderMenu();
App.videos.resetAndReload();
};
App.ui.channels.render();
App.ui.renderFilters(filtersContainer, session);
@@ -827,5 +1166,12 @@ App.ui = App.ui || {};
App.ui.closeInfo();
});
}
const infoCopy = document.getElementById('info-copy');
if (infoCopy) {
infoCopy.addEventListener('click', () => {
App.ui.copyInfoJson();
});
}
};
})();

View File

@@ -54,9 +54,12 @@ App.version = App.version || {};
// a reload because it is restored from localStorage on boot.
function isSafeToReload() {
if (App.state && App.state.feedOpen) return false;
const player = document.getElementById('custom-player');
if (player && player.classList.contains('open')) {
const video = player.querySelector('.cp-video');
// A player still loading out of sight counts as in use: reloading
// would throw away the video the viewer just asked for.
if (App.player && typeof App.player.isActive === 'function' && App.player.isActive()) {
const player = document.getElementById('custom-player');
const video = player && player.querySelector('.cp-video');
if (!player.classList.contains('open')) return false;
if (video && !video.paused && !video.ended) return false;
}
return true;

File diff suppressed because it is too large Load Diff

206
tests/smoke_channels.py Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Channel picker smoke tests.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_channels.py
The picker replaced a <select>, so what's checked here is what the <select>
couldn't do -- show what a channel *is* (favicon, description, tags, group) and
let it be searched -- plus the two things it could: switching the channel, and
feeding the command palette its list.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottub.spacemoehre.de"
CHANNEL = "xvideos"
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.setItem('favorites', JSON.stringify([]));
}"""
ROWS = """() => {
const list = document.getElementById('channel-picker-list');
const rows = Array.from(list.querySelectorAll('.channel-row'));
return {
sections: Array.from(list.querySelectorAll('.channel-section')).map((s) => s.textContent),
count: rows.length,
withNote: rows.filter((r) => !!r.querySelector('.channel-row-note')).length,
withTags: rows.filter((r) => !!r.querySelector('.channel-tag')).length,
withIconEl: rows.filter((r) => !!r.querySelector('img.channel-favicon')).length,
iconsLoaded: rows.filter((r) => {
const img = r.querySelector('img.channel-favicon');
return img && img.naturalWidth > 0;
}).length,
current: rows.filter((r) => r.classList.contains('is-current'))
.map((r) => r.dataset.channelId),
groupRows: rows.filter((r) => r.dataset.channelId.startsWith('group:')).length,
emptyHidden: document.getElementById('channel-picker-empty').hidden,
};
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def boot(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(2500)
def main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
# Lean flags, matching smoke_grid: this runs alongside the app's own
# server and a default Chromium spikes hard enough at startup to get
# itself killed on a constrained box.
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 1400, "height": 1000})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
boot(page)
print("\nthe menu says which channel is being read")
page.evaluate("() => App.ui.toggleDrawer('menu')")
page.wait_for_timeout(2500)
trigger = page.evaluate("""() => ({
name: document.getElementById('channel-trigger-name').textContent,
note: document.getElementById('channel-trigger-note').textContent,
iconLoaded: document.getElementById('channel-trigger-icon').naturalWidth > 0,
})""")
c.ok("the trigger names the current channel", trigger["name"] == "XVideos", str(trigger))
c.ok("and says something about it", len(trigger["note"]) > 10, str(trigger))
c.ok("and shows its favicon", trigger["iconLoaded"], str(trigger))
print("\nopening it lists the channels with what the server knows")
page.click("#channel-picker-btn")
page.wait_for_timeout(3000)
rows = page.evaluate(ROWS)
c.ok("every channel is listed", rows["count"] > 50, str(rows["count"]))
c.ok("grouped under the server's own headings", len(rows["sections"]) > 1,
str(rows["sections"][:4]))
c.ok("each group can be browsed whole", rows["groupRows"] > 1, str(rows["groupRows"]))
c.ok("rows carry a description", rows["withNote"] > rows["count"] // 2, str(rows))
c.ok("rows carry tags", rows["withTags"] > rows["count"] // 2, str(rows))
c.ok("rows carry a favicon", rows["withIconEl"] > rows["count"] // 2, str(rows))
c.ok("and the favicons actually load", rows["iconsLoaded"] > 10,
f"{rows['iconsLoaded']} of {rows['withIconEl']} loaded")
c.ok("the current channel is marked", rows["current"] == [CHANNEL], str(rows["current"]))
print("\nsearching narrows it")
page.fill("#channel-search", "hentai")
page.wait_for_timeout(600)
found = page.evaluate(ROWS)
c.ok("fewer rows than before", 0 < found["count"] < rows["count"], str(found["count"]))
# A row can match on its own text or on the group it sits under -- the
# heading is as much a fact about the channel as its description.
c.ok("every row left standing matches what was typed",
page.evaluate("""() => {
let heading = '';
return Array.from(document.getElementById('channel-picker-list').children)
.every((node) => {
if (node.classList.contains('channel-section')) {
heading = node.textContent.toLowerCase();
return true;
}
const text = (node.textContent + ' ' + node.dataset.channelId + ' ' + heading);
return text.toLowerCase().includes('hentai');
});
}"""))
# Description and tags are searched too, not just the name.
page.fill("#channel-search", "leaks")
page.wait_for_timeout(600)
by_tag = page.evaluate(ROWS)
c.ok("a tag finds channels whose name doesn't say it", by_tag["count"] > 0,
str(by_tag["count"]))
page.fill("#channel-search", "zzzznothing")
page.wait_for_timeout(600)
nothing = page.evaluate(ROWS)
c.ok("a search with no hits says so",
nothing["count"] == 0 and not nothing["emptyHidden"], str(nothing))
print("\npicking one switches the feed")
page.fill("#channel-search", "eporner")
page.wait_for_timeout(600)
page.click(".channel-row")
page.wait_for_timeout(1500)
c.ok("the picker closes",
page.evaluate("() => !document.getElementById('channel-picker').classList.contains('open')"))
session = page.evaluate("() => App.storage.getSession().channel.id")
c.ok("the session moved to it", session == "eporner", session)
c.ok("the trigger followed",
page.evaluate("() => document.getElementById('channel-trigger-name').textContent") == "EPorner")
page.wait_for_selector(".video-card", timeout=90000)
c.ok("and the grid reloaded",
page.evaluate("() => App.state.loadedVideos.length > 0"))
print("\nthe keyboard works too")
page.click("#channel-picker-btn")
page.wait_for_timeout(1200)
page.fill("#channel-search", "beeg")
page.wait_for_timeout(500)
page.press("#channel-search", "ArrowDown")
page.press("#channel-search", "ArrowUp")
page.press("#channel-search", "Enter")
page.wait_for_timeout(1500)
c.ok("Enter picks the highlighted row",
page.evaluate("() => App.storage.getSession().channel.id") == "beeg",
page.evaluate("() => App.storage.getSession().channel.id"))
page.click("#channel-picker-btn")
page.wait_for_timeout(800)
page.press("#channel-search", "Escape")
page.wait_for_timeout(300)
c.ok("Escape closes it without changing anything",
page.evaluate("""() => !document.getElementById('channel-picker').classList.contains('open')
&& App.storage.getSession().channel.id === 'beeg'"""))
print("\nthe command palette still offers the channels")
entries = page.evaluate("() => App.ui.channels.entries().length")
c.ok("the picker hands out its list", entries > 50, str(entries))
page.evaluate("() => App.enhance.openPalette()")
page.wait_for_timeout(400)
page.fill("#cmdk-input", "Redtube")
page.wait_for_timeout(400)
page.press("#cmdk-input", "Enter")
page.wait_for_timeout(1500)
c.ok("and choosing one from there switches the channel",
page.evaluate("() => App.storage.getSession().channel.id") == "redtube",
page.evaluate("() => App.storage.getSession().channel.id"))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())

175
tests/smoke_docpip.py Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Document picture-in-picture: the reel, in a window, scrolling.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_docpip.py
A video picture-in-picture window renders one <video>'s frames and cannot
scroll. Document picture-in-picture opens a real document instead, so the feed
is *moved* into it -- the same elements, another window. That is what these
checks are about: the move must be a move (no rebuild, playback intact), the
window must scroll the reel for real, and closing it must put the feed back
where it came from.
"""
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');
}"""
# The feed can be read from either document. Only App.state lives in the page --
# the window's document has no scripts of its own; it holds the moved elements,
# whose handlers are still the page's closures. That is the whole design, so the
# probe takes the document to look in and always runs in the page.
PROBE = """(where) => {
const doc = where === 'pip'
? (documentPictureInPicture.window && documentPictureInPicture.window.document)
: document;
if (!doc) return null;
const root = doc.getElementById('feed-view');
const active = doc.querySelector('.feed-slide.is-active');
const scroller = doc.getElementById('feed-scroll');
return {
rooted_here: !!root,
slides: doc.querySelectorAll('.feed-slide').length,
step: App.state.feedActiveIndex,
video: App.state.feedActiveVideoId,
playing: active ? Array.from(active.querySelectorAll('.feed-video'))
.map(v => !v.paused && v.readyState >= 2) : null,
scrollable: scroller ? scroller.scrollHeight > scroller.clientHeight + 1 : false,
unique: (() => {
const ids = Array.from(doc.querySelectorAll('.feed-pane')).map(p => p.dataset.videoId);
return ids.length === new Set(ids).size;
})(),
};
}"""
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 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",
])
context = browser.new_context(viewport={"width": 1400, "height": 1000})
page = context.new_page()
open_reels(page)
c.ok("this browser offers document picture-in-picture",
page.evaluate("() => App.feed.docPipSupported()"))
before = page.evaluate(PROBE, 'page')
c.ok("the feed starts in the page", before["rooted_here"])
c.ok("and is scrollable there", before["scrollable"])
print("\nthe picture-in-picture button opens a window")
# A click, not an evaluate: requestWindow needs a user gesture, which is
# exactly the reason the tab-switch path cannot use this API.
with context.expect_page(timeout=15000) as caught:
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
pip = caught.value
pip.wait_for_timeout(3000)
c.ok("the feed reports a window open", page.evaluate("() => App.feed.docPipOpen()"))
c.ok("the feed left the page", not page.evaluate(
"() => !!document.getElementById('feed-view')"))
c.ok("the page underneath is usable again", page.evaluate(
"() => document.body.style.overflow === 'auto'"))
moved = page.evaluate(PROBE, 'pip')
c.ok("the feed is in the window", moved["rooted_here"], str(moved))
c.ok("its slides came with it", moved["slides"] > 0, str(moved["slides"]))
# The point of moving rather than rebuilding: the <video> elements are
# the same ones, so nothing reloads and nothing stops.
c.ok("playback survived the move", moved["playing"] and all(moved["playing"]),
str(moved["playing"]))
c.ok("it landed on the same video", moved["video"] == before["video"],
f"{before['video']} -> {moved['video']}")
print("\nthe window scrolls the reel")
c.ok("the window's feed is scrollable", moved["scrollable"], str(moved))
pip.evaluate("() => { const s = document.getElementById('feed-scroll');"
" s.scrollTop += s.clientHeight; }")
pip.wait_for_timeout(3500)
scrolled = page.evaluate(PROBE, 'pip')
c.ok("scrolling moved to the next step", scrolled["step"] == moved["step"] + 1,
f"{moved['step']} -> {scrolled['step']}")
c.ok("and to another video", scrolled["video"] != moved["video"],
f"{moved['video']} -> {scrolled['video']}")
c.ok("the new step is playing", scrolled["playing"] and all(scrolled["playing"]),
str(scrolled["playing"]))
print("\nclosing the window brings the feed home")
pip.close()
page.wait_for_timeout(3500)
back = page.evaluate(PROBE, 'page')
c.ok("no window is open", not page.evaluate("() => App.feed.docPipOpen()"))
c.ok("the feed is in the page again", back["rooted_here"], str(back))
c.ok("reels is still open", page.evaluate("() => App.feed.isOpen()"))
c.ok("the page is back in reels mode", page.evaluate(
"() => document.body.classList.contains('feed-mode-open')"))
# The window was scrolled while it was out; coming back must not undo it.
c.ok("it kept where the window left off", back["video"] == scrolled["video"],
f"{scrolled['video']} -> {back['video']}")
c.ok("the feed still scrolls here", back["scrollable"], str(back))
c.ok("no video was duplicated by the round trip", back["unique"])
print("\nleaving reels while the window is open")
with context.expect_page(timeout=15000) as caught2:
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
pip2 = caught2.value
pip2.wait_for_timeout(2500)
page.evaluate("() => App.feed.close()")
page.wait_for_timeout(2000)
c.ok("closing reels closes the window", pip2.is_closed() or
not page.evaluate("() => App.feed.docPipOpen()"))
c.ok("the feed came back before it was torn down", page.evaluate(
"() => !!document.getElementById('feed-view')"))
c.ok("the page is scrollable again", page.evaluate(
"() => document.body.style.overflow === 'auto'"))
context.close()
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())

253
tests/smoke_grid.py Normal file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Grid smoke tests.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_grid.py
The load-bearing check here is `content bleed`: every mounted card must render
the video its own data-video-id names. Nothing enforced that before cards were
recycled, because a card was thrown away the moment it left the window; once
cards are reused, a release path that forgets to clear something shows one
video's title, thumbnail or heart on another video's card.
"""
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');
localStorage.setItem('favorites', JSON.stringify([]));
}"""
# Everything a mounted card renders, next to what its own id says it should.
INSPECT = """() => {
const byId = new Map();
(App.state.loadedVideos || []).forEach((v) => byId.set(String(v.id), v));
return Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
const id = card.dataset.videoId;
const v = byId.get(String(id)) || null;
const img = card.querySelector('img');
const dur = card.querySelector('.video-duration');
const up = card.querySelector('.video-uploader');
const fav = card.querySelector('.favorite-btn');
return {
id: id,
known: !!v,
title_shown: (card.querySelector('.video-title-text') || {}).textContent || '',
title_expected: v ? (v.title || '') : null,
// A thumbnail is served either straight from the provider or via
// /api/image?url=<encoded>; compare on the provider URL either way.
src_shown: (() => {
const raw = img ? (img.getAttribute('src') || '') : '';
if (!raw) return '';
try {
const u = new URL(raw, location.href);
return u.pathname === '/api/image'
? (u.searchParams.get('url') || raw) : raw;
} catch (e) { return raw; }
})(),
thumb_expected: v ? (v.thumb || '') : null,
duration_shown: dur && !dur.hidden ? dur.textContent : '',
duration_expected: v ? (App.videos.formatDuration(v.duration) || '') : null,
uploader_shown: up && !up.hidden ? (up.dataset.uploader || up.textContent || '') : '',
uploader_expected: v ? (v.uploader || '') : null,
heart_shown: fav ? fav.classList.contains('is-favorite') : null,
heart_expected: v ? App.favorites.has(v) : null,
stale_loading: card.classList.contains('is-loading'),
stale_pop: fav ? fav.classList.contains('just-favorited') : false,
};
});
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def boot(page):
"""Seed a known server/channel, then wait for the grid to fill.
Startup renders from the status cached in localStorage and refreshes it in
the background, so the first visit has to wait for that round trip before
any video is loaded.
"""
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:
# One reload, in case the status refresh or the listing request failed.
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(3000)
def grow(page, want=80, tries=14):
"""Load enough videos that the grid is taller than the mount window.
The virtualizer keeps everything within 1.2 viewports of the screen mounted,
so a short list never unmounts anything and never exercises recycling.
"""
for _ in range(tries):
if page.evaluate("() => App.state.loadedVideos.length") >= want:
break
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
page.wait_for_timeout(2000)
return page.evaluate("() => App.state.loadedVideos.length")
def scroll_around(page, downs=10):
"""Churn the mount/unmount path: far down, then back to the top."""
for _ in range(downs):
page.evaluate("() => window.scrollBy(0, window.innerHeight * 1.5)")
page.wait_for_timeout(500)
page.wait_for_timeout(1200)
page.evaluate("() => window.scrollTo(0, 0)")
page.wait_for_timeout(1200)
for _ in range(downs // 2):
page.evaluate("() => window.scrollBy(0, window.innerHeight * 2.5)")
page.wait_for_timeout(400)
page.wait_for_timeout(1500)
def check_cards(c, cards, phase):
print(f"\n{phase}: {len(cards)} cards mounted")
c.ok(f"{phase}: cards are mounted", len(cards) > 0)
c.ok(f"{phase}: every card's id is a loaded video",
all(x["known"] for x in cards),
str([x["id"] for x in cards if not x["known"]][:3]))
ids = [x["id"] for x in cards]
c.ok(f"{phase}: no duplicate cards for one video", len(ids) == len(set(ids)))
for field in ("title", "duration", "uploader"):
bad = [x for x in cards
if x["known"] and (x[f"{field}_shown"] or "") != (x[f"{field}_expected"] or "")]
c.ok(f"{phase}: {field} matches the card's own video", not bad,
f"{len(bad)} mismatched, e.g. id={bad[0]['id']} "
f"shown={bad[0][f'{field}_shown']!r} expected={bad[0][f'{field}_expected']!r}"
if bad else "")
# The thumbnail may be served direct or through /api/image, so compare on
# the underlying provider URL rather than the literal src.
bad_src = [x for x in cards if x["known"] and x["src_shown"]
and x["thumb_expected"] and x["thumb_expected"] not in x["src_shown"]
and x["thumb_expected"].split("?")[0] not in x["src_shown"]]
c.ok(f"{phase}: thumbnail belongs to the card's own video", not bad_src,
f"{len(bad_src)} mismatched, e.g. id={bad_src[0]['id']}" if bad_src else "")
bad_heart = [x for x in cards if x["known"] and x["heart_shown"] != x["heart_expected"]]
c.ok(f"{phase}: heart state matches the card's own video", not bad_heart,
f"{len(bad_heart)} mismatched" if bad_heart else "")
stale = [x for x in cards if x["stale_loading"]]
c.ok(f"{phase}: no card left in the loading state", not stale,
f"{len(stale)} stuck" if stale else "")
# The favourite pop is animated away by animationend, which never fires on a
# card released mid-animation -- so it can ride into the pool and replay on
# whatever video the card is bound to next.
popping = [x for x in cards if x["stale_pop"]]
c.ok(f"{phase}: no card replaying the favourite animation", not popping,
f"{len(popping)} popping, e.g. id={popping[0]['id']}" if popping else "")
def main():
c = Checks()
with sync_playwright() as p:
# Lean launch flags: this runs alongside the app's own server, and a
# default Chromium spikes hard enough at startup to get itself killed on
# a constrained box.
browser = p.chromium.launch(args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 1400, "height": 1000})
boot(page)
check_cards(c, page.evaluate(INSPECT), "on first render")
# Informational: how many videos it took to outgrow the mount window
# varies with viewport and page size. Whether that was *enough* is
# asserted properly at the end, on the pool's hit rate.
print(f"\ngrew the listing to {grow(page)} videos")
scroll_around(page)
check_cards(c, page.evaluate(INSPECT), "after scrolling down and back")
# Favouriting must land on the clicked card and survive remounting.
page.evaluate("""() => {
const card = document.querySelector('#video-grid .video-card');
card.querySelector('.favorite-btn').click();
}""")
page.wait_for_timeout(800)
favourited = page.evaluate("() => App.favorites.getAll().map(f => f.key)")
c.ok("favouriting stores exactly one entry", len(favourited) == 1, str(favourited))
scroll_around(page, downs=4)
cards = page.evaluate(INSPECT)
check_cards(c, cards, "after favouriting and scrolling")
# The menu still opens on a card that has been through the cycle.
opened = page.evaluate("""() => {
const card = document.querySelector('#video-grid .video-card');
card.querySelector('.video-menu-btn').click();
return card.querySelector('.video-menu').classList.contains('open');
}""")
c.ok("the card menu opens after recycling", opened)
# Tag clicks read the button's own text now, not a data attribute.
searched = page.evaluate("""() => {
const tag = document.querySelector('#video-grid .video-card .video-tag');
if (!tag) return 'no-tags';
const label = tag.textContent;
tag.click();
return document.getElementById('search-input').value === label ? 'ok' : 'mismatch';
}""")
c.ok("clicking a tag searches for it", searched in ("ok", "no-tags"), searched)
page.wait_for_timeout(1500)
stats = page.evaluate("""
() => (App.virtualGrid.stats && App.virtualGrid.stats()) || null
""")
if stats:
built = stats.get("built", 0)
recycled = stats.get("recycled", 0)
readymade = stats.get("prepared", 0)
total = (built + recycled + readymade) or 1
reused = 100 * (recycled + readymade) // total
print(f"\nmounts: {total} -- {built} built, {recycled} recycled, "
f"{readymade} prepared ahead; {stats.get('pooled', 0)} idle in pool, "
f"{stats.get('readied', 0)} still readied")
# If almost everything is still built per mount, none of the checks
# above actually exercised a reused card.
c.ok("cards are reused rather than rebuilt", reused >= 50, f"{reused}% reused")
c.ok("some cards were prepared before they were needed", readymade > 0,
"prepare-ahead never served a mount")
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())

151
tests/smoke_info.py Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Info panel smoke tests: what the panel shows, and what Copy JSON hands over.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_info.py
The listing is served by this script so the expected JSON is known exactly --
including the awkward parts of a real item (nested http_headers, a tag array,
a zero, an empty string).
"""
import json
import os
import sys
from playwright.sync_api import sync_playwright
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
ITEM = {
"id": "test:copy-me",
"title": "Summer Col and Damion Dayski",
"url": "https://example.test/proxy/test/post/copy-me.html",
"channel": "test",
"duration": 0,
"isLive": False,
"views": 0,
"uploader": "",
"thumb": "https://cdn.example.test/thumb.png",
"tags": ["sex", "bigtits", "bigass", "hardcore"],
"preview": "https://example.test/proxy/test/post/copy-me.html",
"http_headers": {"Referer": "https://example.test/"},
}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.setItem('favorites', JSON.stringify([]));
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
# Lean flags, matching smoke_grid: this runs alongside the app's own
# server and a default Chromium spikes hard enough at startup to get
# itself killed on a constrained box.
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
context = browser.new_context(viewport={"width": 1400, "height": 1000})
# The real clipboard, so the button's own path is what runs.
context.grant_permissions(["clipboard-read", "clipboard-write"])
page = context.new_page()
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": [ITEM], "pageInfo": {"hasNextPage": False}})))
page.route("**/api/resolve*", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"formats": [], "http_headers": {}, "isLive": False, "url": None})))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=60000)
page.wait_for_timeout(1500)
print("\nthe panel opens with a copy button")
page.evaluate("""() => {
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
App.ui.openInfo(v);
}""")
page.wait_for_timeout(500)
c.ok("the panel is open",
page.evaluate("() => document.getElementById('info-modal').classList.contains('open')"))
c.ok("the button is visible", page.is_visible("#info-copy"))
c.ok("and reads Copy JSON",
page.inner_text("#info-copy").strip() == "Copy JSON",
page.inner_text("#info-copy"))
print("\nclicking it puts the item on the clipboard")
page.click("#info-copy")
page.wait_for_timeout(600)
clipboard = page.evaluate("() => navigator.clipboard.readText()")
try:
copied = json.loads(clipboard)
except ValueError:
copied = None
c.ok("what landed there is JSON", copied is not None, clipboard[:120])
if copied:
missing = [k for k, v in ITEM.items() if k not in copied]
c.ok("with every field the item has", not missing, str(missing))
c.ok("values intact, nested ones included",
copied.get("http_headers") == ITEM["http_headers"]
and copied.get("tags") == ITEM["tags"], str(copied.get("http_headers")))
c.ok("a zero is a zero, not a dash",
copied.get("duration") == 0 and copied.get("views") == 0, str(copied.get("duration")))
c.ok("and an empty string stays empty",
copied.get("uploader") == "", repr(copied.get("uploader")))
c.ok("it is pretty-printed, not one line", "\n" in clipboard)
c.ok("the button says so", page.inner_text("#info-copy").strip() == "Copied",
page.inner_text("#info-copy"))
print("\nand it goes back to normal")
page.wait_for_timeout(2000)
c.ok("the label resets", page.inner_text("#info-copy").strip() == "Copy JSON",
page.inner_text("#info-copy"))
print("\nthe resolved payload rides along once it lands")
page.evaluate("""() => {
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
App.ui.showInfo(v, { info: { extractorField: 'yes', formats: [] } });
}""")
page.wait_for_timeout(300)
page.click("#info-copy")
page.wait_for_timeout(600)
second = json.loads(page.evaluate("() => navigator.clipboard.readText()"))
c.ok("the extractor's fields are in there too",
(second.get("extractor") or {}).get("extractorField") == "yes",
str(list(second.keys())))
c.ok("and the item's own are still there", second.get("id") == ITEM["id"])
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())

170
tests/smoke_ios_pip.py Normal file
View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Picture-in-picture on iOS, where the standard API does not exist.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_ios_pip.py
Safari on iPhone and iPad never implemented requestPictureInPicture. It has
picture-in-picture -- it just reaches it through WebKit's older
presentation-mode switch, and document.pictureInPictureEnabled is undefined,
so every capability check answered "no" and the button was hidden on the one
platform where people most want it.
There is no iPhone here, so the browser is reshaped to have iOS's API surface
instead: the standard entry points are deleted and WebKit's are installed. That
is enough to test what actually broke, because what broke was which API the
code reaches for -- not what the browser does once it is called.
"""
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');
}"""
# Runs before any page script, so the app only ever sees the iOS shape.
AS_IOS = """(() => {
delete Document.prototype.pictureInPictureEnabled;
delete Document.prototype.pictureInPictureElement;
delete Document.prototype.exitPictureInPicture;
delete HTMLVideoElement.prototype.requestPictureInPicture;
delete HTMLVideoElement.prototype.disablePictureInPicture;
delete window.documentPictureInPicture;
window.__pipCalls = [];
Object.defineProperty(HTMLVideoElement.prototype, 'webkitPresentationMode', {
configurable: true,
get() { return this.__mode || 'inline'; },
});
HTMLVideoElement.prototype.webkitSupportsPresentationMode = function() { return true; };
HTMLVideoElement.prototype.webkitSetPresentationMode = function(mode) {
window.__pipCalls.push(mode);
this.__mode = mode;
// WebKit's event, which notably does not bubble.
this.dispatchEvent(new Event('webkitpresentationmodechanged'));
};
})();"""
PINNED = """() => {
const slide = document.querySelector('.feed-slide.is-active');
return {
calls: window.__pipCalls.slice(),
pinned: !!(slide && App.feed.pipPinned(slide)),
modes: Array.from(document.querySelectorAll('.feed-video'))
.filter(v => v.webkitPresentationMode === 'picture-in-picture').length,
video: App.state.feedActiveVideoId,
paneVideo: slide ? slide.querySelector('.feed-pane').dataset.videoId : 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 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": 430, "height": 930})
page.add_init_script(AS_IOS)
open_reels(page)
print("\nthe capability check")
c.ok("the standard API really is gone", page.evaluate(
"() => !document.pictureInPictureEnabled && !window.documentPictureInPicture"))
c.ok("picture-in-picture is still reported as available",
page.evaluate("() => App.customPlayer.supportsPiP()"))
c.ok("the button is not hidden", page.evaluate(
"""() => { const b = document.querySelector('.feed-slide.is-active .feed-pip-btn');
return !!b && !b.hidden; }"""))
print("\nthe button asks WebKit for it")
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
page.wait_for_timeout(1500)
entered = page.evaluate(PINNED)
c.ok("it called webkitSetPresentationMode",
entered["calls"] == ["picture-in-picture"], str(entered["calls"]))
c.ok("exactly one video went to the window", entered["modes"] == 1, str(entered["modes"]))
# WebKit's event does not bubble, so the feed's document-level listener
# only hears about this if the adapter re-fires it.
c.ok("the feed noticed and pinned the pane", entered["pinned"], str(entered))
print("\nmoving through the reel from the window")
page.evaluate("() => App.feed.pipStep(1)")
page.wait_for_timeout(3000)
stepped = page.evaluate(PINNED)
c.ok("the pinned pane moved to another video",
stepped["paneVideo"] != entered["paneVideo"],
f"{entered['paneVideo']} -> {stepped['paneVideo']}")
c.ok("it is still the video in the window", stepped["pinned"], str(stepped))
c.ok("and still only one", stepped["modes"] == 1, str(stepped["modes"]))
print("\nleaving the window")
# What iOS does when the reader taps the window's close control.
page.evaluate("""() => document.querySelectorAll('.feed-video').forEach((v) => {
if (v.webkitPresentationMode === 'picture-in-picture') v.webkitSetPresentationMode('inline');
})""")
page.wait_for_timeout(3000)
left = page.evaluate(PINNED)
c.ok("the pin was released", not left["pinned"], str(left))
c.ok("nothing is left in a window", left["modes"] == 0, str(left["modes"]))
c.ok("the feed landed on the video the window ended on",
left["video"] == stepped["paneVideo"],
f"{stepped['paneVideo']} -> {left['video']}")
c.ok("no video is shown twice after the rebuild", page.evaluate(
"""() => { const ids = Array.from(document.querySelectorAll('.feed-pane'))
.map(p => p.dataset.videoId);
return ids.length === new Set(ids).size; }"""))
print("\nthe button toggles back off")
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
page.wait_for_timeout(1500)
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
page.wait_for_timeout(1500)
toggled = page.evaluate(PINNED)
c.ok("the second press asked to go back inline",
toggled["calls"][-1] == "inline", str(toggled["calls"]))
c.ok("nothing is left in a window", toggled["modes"] == 0, str(toggled["modes"]))
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())

179
tests/smoke_ios_split.py Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Split reels on iOS: every panel keeps playing, one panel keeps the sound.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_ios_split.py
There is no iPhone here, and the bug was never about what the video element
does once asked -- it is about what iOS does to the *other* video when one
starts with sound. So the rule is installed into Chromium and the real feed is
driven through it: unmuting a panel used to unmute every panel, and the system
would then stop all but one of them.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
IPHONE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1")
# iOS's rule, near enough: one audible video at a time. Whatever starts (or
# unmutes) with sound takes the audio session, and whatever had it is paused.
AS_IOS = """(() => {
window.__systemPaused = [];
const claimAudio = (winner) => {
document.querySelectorAll('video').forEach((other) => {
if (other === winner || other.paused || other.muted) return;
window.__systemPaused.push(other.currentSrc || 'video');
HTMLMediaElement.prototype.pause.call(other);
});
};
const play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
if (!this.muted) claimAudio(this);
return play.apply(this, arguments);
};
const muted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
configurable: true,
get() { return muted.get.call(this); },
set(value) {
muted.set.call(this, value);
if (!value && !this.paused) claimAudio(this);
},
});
})();"""
PANES = """() => {
const slide = document.querySelector('.feed-slide.is-active');
if (!slide) return null;
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
return panes.map((pane) => {
const video = pane.querySelector('.feed-video');
const btn = pane.querySelector('.feed-pane-mute');
return {
muted: video ? video.muted : null,
flag: pane._muted === undefined ? null : !!pane._muted,
paused: video ? video.paused : null,
loaded: pane.classList.contains('is-loaded'),
button: btn ? btn.textContent : '',
};
});
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def open_reels(page):
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate("""([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}""", [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(1500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
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",
"--autoplay-policy=no-user-gesture-required",
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
context = browser.new_context(
viewport={"width": 430, "height": 930},
user_agent=IPHONE_UA, is_mobile=True, has_touch=True,
device_scale_factor=3)
context.add_init_script(AS_IOS)
page = context.new_page()
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
open_reels(page)
print("\nthe rule is really in force")
c.ok("the page says it is an iPhone",
"iPhone" in page.evaluate("() => navigator.userAgent"))
print("\nsplit in two, with sound on")
page.click(".feed-slide.is-active .feed-pane .feed-pane-split-right")
page.wait_for_timeout(5000)
page.evaluate("() => App.feed.toggleMute()") # the feed-wide unmute
page.wait_for_timeout(3000)
panes = page.evaluate(PANES)
c.ok("there are two panels", panes and len(panes) == 2, str(panes))
audible = [p for p in panes if p["muted"] is False]
c.ok("exactly one of them has sound", len(audible) == 1, str(panes))
c.ok("and the other says so on its own button",
all(p["button"] == "🔇" for p in panes if p["muted"]), str(panes))
c.ok("the flags agree with the elements",
all(p["flag"] == p["muted"] for p in panes), str(panes))
c.ok("both panels are playing",
all(p["paused"] is False for p in panes if p["loaded"]), str(panes))
print("\nmoving the sound to the other panel")
page.click(".feed-slide.is-active .feed-pane:last-of-type .feed-pane-mute")
page.wait_for_timeout(2500)
moved = page.evaluate(PANES)
c.ok("the panel asked for has it", moved[-1]["muted"] is False, str(moved))
c.ok("the first one gave it up", moved[0]["muted"] is True, str(moved))
c.ok("and nothing stopped playing",
all(p["paused"] is False for p in moved if p["loaded"]), str(moved))
print("\nswiping on")
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
" s.scrollTop += s.clientHeight; }")
page.wait_for_timeout(5000)
stepped = page.evaluate(PANES)
c.ok("the next step plays in both panels",
stepped and all(p["paused"] is False for p in stepped if p["loaded"]),
str(stepped))
c.ok("still only one audible", len([p for p in stepped if p["muted"] is False]) <= 1,
str(stepped))
# Without the fix, both panels would be unmuted -- so prove the rule
# installed above actually bites, by breaking it on purpose.
print("\nthe simulated rule is what the fix is for")
page.evaluate("""() => {
document.querySelectorAll('.feed-slide.is-active .feed-video').forEach((v) => {
v.muted = false;
const p = v.play();
if (p && p.catch) p.catch(() => {});
});
}""")
page.wait_for_timeout(1500)
forced = page.evaluate(PANES)
c.ok("unmuting every panel does stop one of them",
any(p["paused"] for p in forced if p["loaded"]), str(forced))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())

276
tests/smoke_open.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Opening a video: load first, show the player once there's something to show.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_open.py
Both the listing and the media are served by this script, so "the stream is
slow" and "the stream is broken" are conditions the test can actually create
rather than wait for.
"""
import base64
import http.server
import json
import os
import socketserver
import sys
import threading
import time
from playwright.sync_api import sync_playwright
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
# One second of black, 64x64, h264 -- a real file, because the point of the
# test is that the browser decodes a frame from it.
MP4_B64 = (
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
)
def make_server(delay_holder):
"""Serves the fixture video, optionally after a delay, plus a dead URL."""
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_GET(self):
if self.path.startswith("/slow") or self.path.startswith("/fast"):
time.sleep(delay_holder["slow"] if self.path.startswith("/slow") else 0)
body = base64.b64decode(MP4_B64)
self.send_response(200)
self.send_header("Content-Type", "video/mp4")
self.send_header("Content-Length", str(len(body)))
self.send_header("Accept-Ranges", "none")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
return
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
def do_HEAD(self):
self.send_response(200)
self.send_header("Content-Type", "video/mp4")
self.send_header("Content-Length", "0")
self.end_headers()
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
httpd.daemon_threads = True
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.setItem('favorites', JSON.stringify([]));
}"""
STATE = """() => {
const player = document.getElementById('custom-player');
const video = player.querySelector('.cp-video');
const card = document.querySelector('#video-grid .video-card');
return {
shown: player.classList.contains('open'),
preloading: player.classList.contains('is-preloading'),
active: !!(App.player.isActive && App.player.isActive()),
cardBusy: !!(card && card.classList.contains('is-loading')),
src: video ? (video.currentSrc || video.getAttribute('src') || '') : '',
readyState: video ? video.readyState : -1,
muted: video ? video.muted : null,
bodyOverflow: document.body.style.overflow,
error: !!(player.querySelector('.cp-error') && !player.querySelector('.cp-error').hidden),
};
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def item(video_url, ident="test:0"):
"""One listing item whose formats are already resolved, so opening it goes
straight to playback rather than through a resolve first."""
return {
"id": ident,
"title": "A video",
"url": "https://example.test/watch",
"channel": "test",
"duration": 1,
"thumb": "",
"tags": [],
"meta": {
"url": video_url,
"http_headers": {},
"isLive": False,
"formats": [{"url": video_url, "ext": "mp4", "height": 240, "protocol": "https",
"vcodec": "avc1", "acodec": "none"}],
},
}
def open_first(page):
"""Click the first card. Forced, because the hover preview parks a <video>
over the thumbnail -- which is inside the card and so opens it just the
same, but Playwright won't click through it on its own."""
page.click("#video-grid .video-card", force=True)
def main():
c = Checks()
delay = {"slow": 0}
httpd = make_server(delay)
media = f"http://127.0.0.1:{httpd.server_address[1]}"
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--autoplay-policy=no-user-gesture-required",
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 1400, "height": 1000})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
items = {"body": [item(f"{media}/fast.mp4")]}
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items["body"], "pageInfo": {"hasNextPage": False}})))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=60000)
page.wait_for_timeout(800)
print("\na video that loads quickly")
open_first(page)
page.wait_for_timeout(2500)
after = page.evaluate(STATE)
c.ok("the player is on screen", after["shown"], str(after))
c.ok("it is no longer preloading", not after["preloading"], str(after))
c.ok("with data, not an empty frame", after["readyState"] >= 2, str(after["readyState"]))
c.ok("the card has stopped spinning", not after["cardBusy"], str(after))
c.ok("sound is back on", after["muted"] is False, str(after["muted"]))
c.ok("and the page behind it is locked", after["bodyOverflow"] == "hidden",
after["bodyOverflow"])
page.evaluate("() => App.player.close()")
page.wait_for_timeout(600)
print("\na video that takes its time")
delay["slow"] = 3.0
items["body"] = [item(f"{media}/slow.mp4", "test:slow")]
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
page.wait_for_selector(".video-card", timeout=60000)
page.wait_for_timeout(600)
open_first(page)
page.wait_for_timeout(900)
during = page.evaluate(STATE)
c.ok("the player is not up yet", not during["shown"], str(during))
c.ok("but the session is live", during["active"] and during["preloading"], str(during))
c.ok("the card says it is working on it", during["cardBusy"], str(during))
c.ok("nothing can be heard from it", during["muted"] is True, str(during["muted"]))
c.ok("and the page is still the page", during["bodyOverflow"] != "hidden",
during["bodyOverflow"])
# The grid is still usable underneath: the invisible player must not be
# eating clicks.
c.ok("the grid underneath is still reachable",
page.evaluate("""() => {
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
return !!(el && !el.closest('#custom-player'));
}"""))
page.wait_for_timeout(4000)
later = page.evaluate(STATE)
c.ok("once the data lands, the player appears", later["shown"], str(later))
c.ok("and the card is released", not later["cardBusy"], str(later))
page.evaluate("() => App.player.close()")
page.wait_for_timeout(600)
print("\nchanging your mind while it loads")
delay["slow"] = 8.0
open_first(page)
page.wait_for_timeout(700)
c.ok("Escape cancels a pending open",
page.evaluate("""() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
return true;
}"""))
page.wait_for_timeout(600)
cancelled = page.evaluate(STATE)
c.ok("nothing is left running", not cancelled["active"], str(cancelled))
c.ok("the player never appeared", not cancelled["shown"], str(cancelled))
c.ok("and the card is free again", not cancelled["cardBusy"], str(cancelled))
print("\na video that will not load at all")
items["body"] = [item(f"{media}/nope.mp4", "test:dead")]
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
page.wait_for_selector(".video-card", timeout=60000)
page.wait_for_timeout(600)
open_first(page)
page.wait_for_timeout(6000)
failed = page.evaluate(STATE)
c.ok("the player is shown so the failure can be read", failed["shown"], str(failed))
c.ok("with its error up", failed["error"], str(failed))
c.ok("and the card no longer spins", not failed["cardBusy"], str(failed))
page.evaluate("() => App.player.close()")
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
httpd.shutdown()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())

176
tests/smoke_reels.py Executable file
View File

@@ -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())

333
tests/smoke_thumbnails.py Normal file
View File

@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""Thumbnail smoke tests: junk URLs, and images that fail once.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_thumbnails.py
The listing and the image hosts are both served by this script rather than by a
provider, because what's under test is what the client does with awkward data:
* a `thumb` that isn't a URL at all -- sxyprn's "latest" listing sends items
whose thumb is the bare string "https:". Resolved against the page that is
*our own* address, so the card used to race our own HTML as if it were a
picture, pin our origin to the proxy for the rest of the session, and ask
/api/image to fetch "https:" (a 400, every time).
* a thumbnail whose first request fails. One blip used to mean an empty box
for as long as the card stayed mounted.
* a thumbnail that is simply gone. sxyprn's CDN paths are signed with an
expiry and the listing hands out URLs that have passed theirs, so the
newest posts arrive pointing at a 404. The item's own page still shows a
working picture, and /api/poster reads it off.
"""
import base64
import json
import os
import sys
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from playwright.sync_api import sync_playwright
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
CDN = "https://cdn.example-thumbs.test"
# Where a "provider page" lives, and the picture it says it has -- the shape
# /api/poster reads. The proxy path mirrors the Hot Tub server's own, so the
# client has to rebuild the site address from the item's Referer to find it.
SITE = "https://site.example-thumbs.test"
PROXY_PAGE = "https://proxy.example-thumbs.test/proxy/test/post/gone.html"
# 1x1 transparent PNG.
PIXEL = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.setItem('favorites', JSON.stringify([]));
}"""
STATE = """() => Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
const img = card.querySelector('img');
return {
id: card.dataset.videoId,
src: img ? img.getAttribute('src') || '' : '',
loaded: img ? img.naturalWidth > 0 : false,
};
})"""
PAGE_HTML = """<!doctype html><html><head>
<meta property='og:title' content='A video'/>
<meta property='og:image' content='//pictures.example.test/fresh/poster.webp'/>
<meta itemprop="thumbnailUrl" content="//pictures.example.test/other.webp" />
</head><body><video poster='//pictures.example.test/player.webp'></video></body></html>"""
class PageServer(threading.Thread):
"""A page for /api/poster to read, and one that redirects to a video --
which is what the Hot Tub proxy does, and what must not be downloaded."""
def __init__(self):
super().__init__(daemon=True)
outer = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path.startswith("/post"):
body = PAGE_HTML.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if self.path.startswith("/video"):
body = b"\0" * (4 * 1024 * 1024)
self.send_response(200)
self.send_header("Content-Type", "video/mp4")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.send_response(404)
self.end_headers()
self.httpd = HTTPServer(("127.0.0.1", 0), Handler)
outer.port = self.httpd.server_port
def run(self):
self.httpd.serve_forever()
def stop(self):
self.httpd.shutdown()
GONE_STATE = """() => {
const card = document.querySelector('#video-grid .video-card[data-video-id="test:9"]');
const img = card && card.querySelector('img');
return { src: img ? img.getAttribute('src') || '' : '', loaded: !!(img && img.naturalWidth > 0) };
}"""
def gone_state(page):
return page.evaluate(GONE_STATE)
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def listing():
"""Twelve ordinary items, one with the junk thumb, one that fails once."""
items = []
for i in range(12):
items.append({
"id": f"test:{i}",
"title": f"Video {i}",
"url": f"{CDN}/watch/{i}",
"channel": "test",
"duration": 60 + i,
"thumb": f"{CDN}/thumb/{i}.png",
"tags": [],
})
items[3]["thumb"] = "https:" # what sxyprn's "latest" actually sends
items[7]["thumb"] = f"{CDN}/flaky.png"
# The expired-signature case: a dead thumbnail, and an item that says
# enough about where it came from for the page to be found.
items[9]["thumb"] = f"{CDN}/expired/gone.jpg"
items[9]["url"] = PROXY_PAGE
items[9]["channel"] = "test"
items[9]["http_headers"] = {"Referer": f"{SITE}/"}
return {"items": items, "pageInfo": {"hasNextPage": False}}
def poster_of(page_url):
"""What /api/poster answers for a page, straight from the backend.
Returns None if the backend doesn't have the endpoint -- which on a machine
that has been running since before it existed means "restart it", not
"broken"."""
url = BASE.rstrip("/") + "/api/poster?url=" + urllib.parse.quote(page_url, safe="")
try:
with urllib.request.urlopen(url, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as err:
if err.code == 404:
return None
raise
def main():
c = Checks()
image_proxy_calls = []
poster_asks = []
flaky_hits = {"direct": 0, "proxy": 0}
pages = PageServer()
pages.start()
local = f"http://127.0.0.1:{pages.port}"
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
# Lean flags, matching smoke_grid: this runs alongside the app's own
# server and a default Chromium spikes hard enough at startup to get
# itself killed on a constrained box.
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 1400, "height": 1000})
def serve_listing(route):
route.fulfill(status=200, content_type="application/json",
body=json.dumps(listing()))
def serve_proxy(route):
# Only the thumbnails matter here; record what the client asked us
# to fetch on its behalf, and hand back the picture.
image_proxy_calls.append(route.request.url)
if "%2Fexpired%2F" in route.request.url or "/expired/" in route.request.url:
route.fulfill(status=404, content_type="text/html", body="gone")
return
if "flaky.png" in route.request.url:
flaky_hits["proxy"] += 1
# The flaky picture is refused on *both* routes the first time
# round, which is what used to leave the card empty for good.
if flaky_hits["proxy"] == 1:
route.fulfill(status=502, content_type="text/plain", body="nope")
return
route.fulfill(status=200, content_type="image/png", body=PIXEL)
def serve_poster(route):
# The endpoint itself is exercised against a real page below; here
# only the client's half is under test, so the answer is canned.
asked = urllib.parse.parse_qs(
urllib.parse.urlparse(route.request.url).query).get("url", [""])[0]
poster_asks.append(asked)
route.fulfill(status=200, content_type="application/json",
body=json.dumps({"thumb": f"{CDN}/repaired.png"}))
def serve_cdn(route):
if "/expired/" in route.request.url:
route.fulfill(status=404, content_type="text/html", body="gone")
return
if route.request.url.endswith("/flaky.png"):
flaky_hits["direct"] += 1
if flaky_hits["direct"] == 1:
route.abort("connectionfailed")
return
route.fulfill(status=200, content_type="image/png", body=PIXEL)
page.route("**/api/videos", serve_listing)
page.route("**/api/poster*", serve_poster)
page.route("**/api/image*", serve_proxy)
page.route(f"{CDN}/**", serve_cdn)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=60000)
# Long enough for the host race (2.5s of patience) and the retry ladder
# (~900ms for its second step) to have run their course.
page.wait_for_timeout(8000)
cards = page.evaluate(STATE)
by_id = {card["id"]: card for card in cards}
print("\na thumb that isn't a URL")
junk = by_id.get("test:3")
c.ok("the card is mounted", junk is not None)
if junk:
c.ok("it asks for nothing at all", junk["src"] == "",
f"src={junk['src']!r}")
c.ok("and nothing is sent to the image proxy for it",
not [u for u in image_proxy_calls if "https%3A&" in u or u.endswith("url=https%3A")],
str([u for u in image_proxy_calls if "https%3A" in u][:2]))
print("\nthe rest of the page is unaffected by it")
# test:9's thumbnail is deliberately dead; it has a section of its own.
others = [card for card in cards if card["id"] not in ("test:3", "test:9")]
c.ok("every other card shows its picture",
all(card["loaded"] for card in others),
str([card["id"] for card in others if not card["loaded"]]))
# The junk URL used to resolve to our own origin, whose race then failed
# and pinned it to the proxy -- for everything, for the whole session.
c.ok("our own origin is not pinned to the proxy",
page.evaluate("() => App.videos.thumbnailUrl(location.origin + '/x.png')")
== page.evaluate("() => location.origin + '/x.png'"))
print("\na thumbnail refused on both routes, once")
flaky = by_id.get("test:7")
c.ok("the card is mounted", flaky is not None)
c.ok("both routes were refused once",
flaky_hits["direct"] >= 1 and flaky_hits["proxy"] >= 1,
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
c.ok("and it was asked for again after that",
flaky_hits["direct"] + flaky_hits["proxy"] > 2,
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
if flaky:
c.ok("so the card ends up showing a picture", flaky["loaded"],
f"src={flaky['src']!r}")
c.ok("and a dead thumbnail stops being asked for",
flaky_hits["direct"] + flaky_hits["proxy"] <= 4,
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
print("\na thumbnail that is simply gone")
gone = by_id.get("test:9")
c.ok("the card is mounted", gone is not None)
c.ok("its page was asked what picture it shows", poster_asks,
str(poster_asks))
# The item points at a proxy path; the page lives on the site named by
# the Referer the item carries.
c.ok("and the page asked for was the item's own, on its own site",
poster_asks and poster_asks[0] == f"{SITE}/post/gone.html",
str(poster_asks[:2]))
c.ok("the card ends up showing the replacement",
gone and gone_state(page)["loaded"], str(gone_state(page)))
c.ok("one ask is enough for that page", len(poster_asks) == 1, str(poster_asks))
print("\nwhat /api/poster reads off a page")
answer = poster_of(f"{local}/post.html")
if answer is None:
c.ok("the backend has /api/poster (restart it if this fails)", False)
answer = {}
else:
c.ok("the backend has /api/poster", True)
c.ok("the page's own og:image, made absolute",
answer.get("thumb") == f"http://pictures.example.test/fresh/poster.webp",
str(answer))
video = poster_of(f"{local}/video.mp4")
c.ok("a URL that turns out to be a video yields nothing",
video.get("thumb") is None, str(video))
missing = poster_of(f"{local}/nope.html")
c.ok("and so does a page that isn't there", missing.get("thumb") is None, str(missing))
browser.close()
pages.stop()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())

159
tests/unit_formats.js Executable file
View File

@@ -0,0 +1,159 @@
#!/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('\nranking by what it costs to decode');
// Same picture, four ways of arriving at it.
const mixed = [
{ url: 'hls720', height: 720, vcodec: 'avc1', protocol: 'm3u8_native', fps: 30 },
{ url: 'av1720', height: 720, vcodec: 'av01.0.05M.08', protocol: 'https', ext: 'mp4', fps: 30 },
{ url: 'avc720', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 30, tbr: 900 },
{ url: 'avc720p60', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 60, tbr: 2000 },
];
const mixedVideo = { id: 'v2', url: 'https://example.com/w2', meta: { formats: mixed } };
sandbox.App.storage.getPreferredQuality = () => 'auto';
const best = (opts) => resolveStreamSource(mixedVideo, opts).url;
ok('without the flag, bitrate still wins', best({}) === 'avc720p60', best({}));
ok('cheapest avoids HLS demuxing in JS', best({ cheapest: true }) !== 'hls720');
ok('cheapest avoids software-decoded AV1', best({ cheapest: true }) !== 'av1720');
ok('cheapest avoids 60fps', best({ cheapest: true }) !== 'avc720p60');
ok('cheapest picks progressive H.264 at 30fps',
best({ cheapest: true }) === 'avc720', best({ cheapest: true }));
// Cheapness must not override the size ceiling, or a split panel would get a
// bigger picture than it can afford just because it is cheap per pixel.
const tall = [
{ url: 'hls480', height: 480, vcodec: 'avc1', protocol: 'm3u8_native' },
{ url: 'mp4_1080', height: 1080, vcodec: 'avc1', protocol: 'https', ext: 'mp4' },
];
const tallVideo = { id: 'v3', url: 'https://example.com/w3', meta: { formats: tall } };
ok('the height ceiling still comes first',
resolveStreamSource(tallVideo, { maxHeight: 480, cheapest: true }).url === 'hls480');
ok('every format stays reachable as fallback',
rankFormats(mixed, null, { cheapest: true }).length === mixed.length);
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
process.exit(failed ? 1 : 0);