Compare commits

...

37 Commits

Author SHA1 Message Date
Simon
c60f5e01dc Re-anchor a moving reel on where it is, not what it was playing
A viewport change re-anchors the reel on the video that is playing,
resolved by id rather than by a scroll position the change has already
invalidated. That is what keeps a rotation on the same video.

Mid-swipe it names the wrong one. setActive runs from onScroll's next
frame, so a resize landing before that frame still reads the video being
swiped *away from* -- and realignToActive writes its position straight to
scrollTop, hauling the reel back to it. A phone slides its chrome on every
scroll, so the resize lands mid-swipe on nearly every swipe: the reel went
where the thumb put it, far enough to show the top of the next video at
the bottom of the screen, and was then written back a beat later.

The input guard in onScroll could not catch this. There is no scroll event
to guard -- the write happens inside the resize.

So while the reader is demonstrably driving, take the reel's own position
as the authority and re-anchor on the step it is nearest at the new
height. It still lands on a snap point, so nothing is left straddling two
screens, but the swipe stands. Only a step's worth of disagreement counts
as theirs: a scroller just re-parented into or out of the
picture-in-picture window reads 0, and following that would land them on
the first video instead of their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 22:26:39 +00:00
Simon
7033288bc4 Hold the slides for the whole turn, not until it first pauses
The slides are held at the size a turn is heading for so the picture steps
once, straight into the shape it keeps, instead of being re-letterboxed
through every shape the viewport passes on its way round.

The hold came off once the height had gone six frames unchanged, which
stood in for "the viewport has stopped moving". It does not: a turn
arrives in steps, and the gap between two of them is routinely longer
than six frames -- on a phone and in a loaded tab alike. So the pin came
off mid-turn, the slides took an intermediate shape, and the picture
jumped twice on the way round. That is the stutter the pin exists to
remove, and TURN_HOLD_FRAMES was meant to be "long enough that a viewport
animating round in steps is not mistaken for one that has arrived" -- six
frames is not.

The device does say when a turn is underway, so believe it: while the
announcement stands the pin holds, and the frame count is left to end the
pins nothing announced, where there is no signal to go on and no turn to
ruin. The realign window has to stop concluding mid-turn too, since
closing it releases the pin by the other door; SETTLE_MAX_MS outlasts the
announcement and still guarantees both of them end.

The cost is that an announced turn keeps the old size until the
announcement lapses rather than letting go the moment the viewport looks
still. That buys the one step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 22:26:20 +00:00
Simon
872786a657 Let a swipe count while the viewport is still moving
A viewport change opens a window in which the feed holds the reader's
video in place, because the scroll-snap container re-anchors on positions
measured against the height from before it and must not be allowed to
flip the video. The window answered every scroll it saw with a realign.

On a phone that is the worst moment to do it: the chrome sliding out of
the way is caused by the swipe, so the window stood open at exactly the
moment the reader was swiping. The reel moved under the finger, far
enough to show the top of the next video at the bottom of the screen, and
was then pulled back to where it started -- a second later, once the
settle window ran out.

A re-anchor has nothing behind it and a swipe has a finger or a wheel, so
record the input and let a scroll that follows one close the window
instead of being reverted into it. Closing it that way skips the realign,
or the close would undo the swipe that caused it. Only input that means
to scroll counts -- a finger that has moved rather than one that has
merely landed, since a tap must not sign for the re-anchor after it.

Telling a turn apart from the chrome moving was reading the orientation
flip alone, which a few pixels of height cross whenever the viewport is
near enough to square. That pinned the slides and turned snapping off for
a second over a height change, which is the one thing it is there to tell
a turn apart from; a turn swaps the long side, and swapping it moves the
width too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 21:39:18 +00:00
Simon
7fc718158a Check a direct route hands back the video it was asked for
The proxy exists to get around CORS, hotlink protection and TLS
fingerprinting, so the player probes whether a CDN will serve the browser
itself and skips the hop when it will. The probe took a readable status
line and some bytes as proof.

Not every hotlink guard refuses a foreign Referer with a 403. eporner's
CDN answers one with a 302 to a one-minute "watch this at our site
instead" clip, served 206 with Access-Control-Allow-Origin: *. fetch()
follows that redirect without comment, so the probe read the stand-in as
a win, cached the host as direct, and the player restarted playback on
the raw URL -- where the browser sent the page's own Referer again and
got the stand-in a second time. An eporner video played as 61 seconds of
advertisement instead of itself.

Only the resolved path was affected: yt-dlp's eporner formats carry no
Referer, so refererRequired was false and the race ran. The listing's own
formats name one and were always proxied.

So ask the proxy, which carries the Referer the origin wants, how long
the media is, and refuse a direct route that reports another length. The
question goes out alongside the direct fetch rather than after it, and an
unknown length on either side leaves the old bytes test standing, so
chunked responses and playlists decide as before. No Range header: that
would cost a CORS preflight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-22 20:58:45 +00:00
Simon
4f772773e1 Let the reader have the captured pictures taken again
A frame taken out of a video is kept for good: that is what makes a dead
favorite cheap to scroll past, and it is also the one thing that can go wrong
with no way out. A picture from the wrong moment, or one kept by a version of
this that would keep a frame with nothing in it, stays on the card for the life
of the item.

So Settings grows a Captured Thumbnails section: what is kept and how much room
it takes, and a button that throws the lot away. The cards on screen go back to
the start of the thumbnail ladder rather than waiting to be scrolled out of the
window and back, which is where a fresh capture comes from, and the session's
memory of which items there was no point asking about goes with the pictures --
otherwise "take them again" would quietly skip every item that failed once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-21 21:08:06 +00:00
Simon
be058dba37 Keep a captured thumbnail only if there is a picture in it
A favorite whose thumbnail is gone gets a frame out of the video instead. The
one this came from got a black box: 1.6 KB of pure black, kept for good, on an
item whose video is black nowhere -- ffmpeg puts its opening frame at YAVG 185
and its quarter mark at 130.

So that was not a frame of the video at all. Drawing a video element that has
not presented a frame gives back the canvas untouched, and transparent black
JPEGs as pure black at about 1.6 KB -- comfortably past the only test a capture
had to pass, which was being over 512 bytes long. Nothing then asked whether
the picture had anything in it, and the cache exists precisely so that nothing
asks again.

The frame now has to be a picture of something, and both ways it can fail to be
one have the same answer: take it somewhere else. The brightest sample in the
drawn frame decides -- an untouched canvas has no painted pixels at all, and a
frame that genuinely is black (a lead-in, a fade, a cut) is no better a
thumbnail than one of nothing. A blank frame moves on: the quarter mark again
with the element playing, since an element that is playing has to present
frames while a seeked one does not, and then the half mark. The frame is taken
on `requestVideoFrameCallback` where there is one -- the browser saying a frame
is on screen now -- rather than on `seeked` alone, which Chromium backs with a
decoded frame but Safari does not for an element that is off-screen and paused.

Nobody will say how long this item is (the extractor reports no duration and
the favorite kept a zero), and a clip with no length had no quarter mark to
seek to, so the capture settled for the first frame the element decoded -- the
opening one, which is the frame most likely to be black. It now seeks blind to
a fixed mark, clamped back into the file if that is past the end.

And the black pictures already kept heal: an index entry carries a marker
saying its picture was checked, every capture writes one, and an entry without
one is measured once -- behind the picture the card has already painted -- then
dropped and taken again if it turns out to be a frame of nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-21 20:20:05 +00:00
Simon
6dac1028a3 Take a thumbnail out of the video when the listed one is gone
A favorite outlives its thumbnail. Sources sign their CDN paths and the
signature expires, so a video kept months ago has a `thumb` that 404s and
a card that is a grey box with a title under it. Asking the source page
for a fresh picture covers the case where the page still exists; nothing
covered the case where it doesn't.

The video does, because playback re-resolves from the page URL every time.
So once every route to the listed picture is exhausted, a frame a quarter
of the way in becomes the card's picture -- past the black lead-in, and a
fair guess at what the video looks like. It goes through our own proxy, so
the canvas can be read back, is scaled to card width and JPEG'd to a
handful of KB, and is kept: later mounts and later sessions paint it
without a request, instead of asking again for a picture that is known to
be gone.

Only for favorites, once per item, one at a time, and never for a live
stream or a manifest. Two things worth knowing, both found with the
browser rather than reasoned about: a browser fires `seeked` for its own
initial seek to zero, so taking the frame on the first one captures the
opening of every video; and the same item is on screen in more than one
place, so the captures are deduped by a promise rather than the first
asker getting the picture and the rest being told there isn't one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-20 07:33:38 +00:00
Simon
e8eac70f4d Write a check into the list instead of rebuilding it
A check asks each saved search in turn and writes back what it found, and
the list was built again from scratch on every one of those writes. So a
background check -- which has no business being visible at all beyond a
new count and a new time -- replayed the whole list's entry animation,
threw away the reader's scroll position, and took the pointer out of
whatever row it was over, several times over.

Rows are now matched by id and told what changed: the count, the name, the
line underneath, nothing else. A row is only built when it is new, gone, or
changes shape, and the entry animation belongs to a row that has arrived
rather than to every row every time. One mid-rename or mid-confirm is left
alone entirely, so a check can no longer type over the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-20 07:33:27 +00:00
Simon
d5c859a1c7 Count only the part of a page the reader was shown
`perPage` is a request, not a promise: a source whose own pages are 30
long answers a page of 12 with 30. The grid showed the reader the page it
asked for, the mark kept 24 of those ids, and the daily check then counted
every probe id the mark did not contain -- including ids 25 to 30 of the
same page, which were never new and no amount of looking took off the
badge. A saved search on sxyprn sat on "6" through every reload.

So the count now goes only as far down the page as the mark reaches, and
the mark holds enough ids (60) for a page larger than the one asked for.
Order does not come into it, which keeps it right for sources that shift a
listing between visits.

And the whole list can be marked read in one press, for a reader who sees
the badge and decides none of it is for them. That moves each mark on to
what the source lists now rather than zeroing the count, which would come
straight back on the next check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-19 20:15:17 +00:00
Simon
6d9cc301a7 Turn the phone in one step, not through every shape
A turn is not one shape change: the viewport passes through every shape
between the one it left and the one it lands in, and each of them
re-letterboxes the picture somewhere else. Painted, that run of hard jumps
is the stutter.

So for the length of a turn the slides are held at the size they already
had, and the moment the viewport stops moving they take the size it ended
at -- measured, not guessed. The picture moves exactly once, straight into
the position and size it then keeps. The device's own `orientationchange`
starts the hold, which is the earliest anything can know a turn is coming
and the one signal that separates it from a window being dragged wider;
failing that, the viewport swapping its long side for its short one does. A
viewport that only changes height is the browser's chrome sliding away, and
is never held.

Underneath: snapping is off while the slides are held, so the container
cannot re-anchor on the old height at the same time; a scroll arriving
during the window is put back in the event rather than a frame later, so
the stale position is never painted; and the per-frame realign only writes
what actually differs instead of costing a layout sixty times a second.

Measured over a recorded turn: the picture moves 12 times -> 2, once per
turn; the worst of those was a 660px jump between two intermediate shapes
and is now the one step into the final size; the reel is never off its step
(was 660px out); and the clip never pauses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-19 15:39:36 +00:00
Simon
b9f6a78fdc Stay on the same video when the phone turns
Rotating mid-reel jumped the reel forward a few videos. Two reasons, both
about the same arithmetic: every scroll position in the feed is index *
scroller.clientHeight, with a spacer standing in for the slides above.

A slide was 100dvh, which is not the scroller's height on a phone whose
chrome comes and goes, so that mapping addressed the wrong slide by a
growing margin; it is now the scroller's own height, which it cannot
disagree with.

And the realign window lasted two frames. A rotation is many frames and
several resizes, with the container's re-anchor on pre-rotation pixels
landing in the middle of them -- so the window now stays open until the
slide height has held still for a stretch of frames (with a clock to close
it in a tab that isn't drawing), and orientationchange and the visual
viewport open it too. Moving the feed into a picture-in-picture window
settles the same way.

tests/smoke_rotate.py replays a rotation as the browser delivers it, on a
fixture reel so nothing waits on a real source; against the old code the
reel goes from step 7 to step 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-18 19:40:37 +00:00
Simon
bc7c0047b7 Keep a whole search, and say what is new in it
A search here is more than the words in the box: it is the source, the
channel, every filter that channel offers, and the query together. The
bookmark in the search bar keeps all of it, the one in the header opens
the list, and tapping a row puts the whole thing back.

Each saved search remembers the ids of the newest videos it showed the
last time it was opened. Once a day -- at startup, when the tab is looked
at again, and on a slow heartbeat for tabs left open -- the client asks
each one's first page again and counts how many of those videos it has
never shown. That count is the badge, on the row and summed in the
header, and it stops at 9+. Opening a saved search is viewing it, so the
count goes out and the mark moves to what is on screen now.

Nothing new leaves the device: the check is the same listing request the
grid would have made, and everything else is localStorage.

Also: handleSearch's "put this in the box without searching for it" half
is now App.videos.setSearchBox, which is what applying a saved search
needs.

tests/smoke_saved_searches.py covers it end to end against a fixture
listing whose ids the test shifts to mean "new uploads": 46 checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-17 05:59:19 +00:00
Simon
f977852253 Offer the searches from before while you type
Searches are kept in local storage and the five that best fit what is in
the box are offered under it: the ones it starts, then the ones it starts
a word of, then the ones it appears in anywhere, most recently searched
first. Walk them with the arrows, take one with enter or a tap, dismiss
with escape. The part that matched is picked out, and rows are built from
text nodes -- these strings are the reader's own and they are going back
on screen.

The box searches as you type, so there is no submit to record and storing
every pause would fill the list with the way to a query rather than the
query: a new entry swallows the ones it grew out of ("hot", "hote",
"hotel" leaves "hotel"), and going back to something shorter adds
nothing. Enter records straight away, since that is a reader saying they
meant it.

That turned up a bug the suggestions would have run into constantly: a
search started while the box's own last search was still out was dropped
-- loadVideos saw a load running and returned -- leaving the grid on the
previous query. Changing the result set now stops the load that is
running before starting the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-22 14:52:50 +00:00
Simon
553d610e5b Two panes, not four, on the split-right button
U+229E squared plus draws as a square quartered by a cross, which reads
as four panels rather than the two the button makes. U+25EB squared
vertical bisecting line is the same picture as the split-down button's
squared minus, turned ninety degrees: one square, one line, two panels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-22 12:35:25 +00:00
Simon
adb254c20b Keep the videos already playing when a panel is split
Splitting or closing a panel changes the stride, which renumbers every
step, so the slides are rebuilt -- and the rebuild threw away the panes
along with them. Every video on screen was fetched and buffered again to
show what it was already showing.

The panes are now set aside by video id first, and whoever needs that
video next takes the pane that already has it: the same element, the
same stream, the frames it had already decoded. It all happens in one go
without yielding, so a pane is never out of the document long enough to
be paused for it. Panes the new layout has no place for are torn down as
before -- they are holding a stream open.

Pane tools are rebindable for the same reason: a reused pane comes back
under a different node in the tree, and its old buttons would otherwise
split and close the one it left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-22 12:14:16 +00:00
Simon
8163693150 Load a split step's videos together, and three steps ahead
Preloading was counted in steps and then shared out between the panels,
so with four panels only the very next step was ready and each panel did
its resolving and buffering at the moment it was swiped to -- four
panels, four starts in a row. It is now counted per panel: every panel
keeps its next three videos ready (N * 3 for a step of N), the rendered
window reaches far enough forward to hold them, and the step right
behind the one being watched buffers for real rather than taking its
metadata alone.

Their formats are resolved up front and all at once, rather than one
panel at a time as each is swiped to.

The listing is fetched several pages at a time to keep up: a page number
is claimed before its request goes out, which is what lets pages
overlap, and finished pages wait their turn so the reader still gets
them in order. A page whose request fails puts its number back rather
than leaving a hole. How far ahead to run scales with the split, since a
step of N panels gets through a page N times as fast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-22 09:11:52 +00:00
Simon
1b38f9d9e7 Blur the page whenever the window isn't in front
Losing focus -- another tab, another window, another app -- now puts the
whole page behind a blur, so an app switcher's snapshot or a glance over
the shoulder gives nothing away. It comes back on focus, on the tab
becoming visible again, and on the first touch or key, which lands on the
veil rather than on a page nobody could see.

Painted as a backdrop filter on a fixed overlay above everything the app
can show: a filter on the page itself would make every fixed element
position against the wrong box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-22 08:30:20 +00:00
Simon
741786c7cf Leave the controls down while swiping through reels
Scrolling woke the HUD, so a swipe to the next reel put the controls up
over the video arriving -- the one thing the swipe was for -- and left them
there for a second. The scroll now puts them away instead.

Removing that alone wasn't enough: the container also woke on pointerdown
and touchstart, which is how every drag begins, so the HUD still flashed at
the start of one. A press only wakes them now if it lands on a control --
which is a press on something the reader can't see while they're hidden,
and never the start of a swipe. A press that turns out to be a tap still
wakes them through the gesture layer's onSingleTap, the mouse moving still
wakes them, and dragging the scrubber keeps them up, being the one drag
that is the HUD.

Two details follow from it. The sleep sits after onScroll's suppressScroll
guard, so a resize or a picture-in-picture re-snap doesn't count as the
reader moving. And opening the feed scrolls to the starting step, whose
scroll event would put the controls straight back down, so that wake waits
a frame -- scroll events run before animation frames.

The checks live in their own file rather than in smoke_reels: one panel and
one swipe is all they need, and the split-panel suite is the heaviest thing
in the tree to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 17:57:04 +00:00
Simon
39a47289b9 Start the next reel as it slides in, not once it has landed
A step began playing when the swipe was already half done -- Math.round of
the scroll position -- and only then started loading. So it slid into view
as a still picture and came to life some time after it arrived, which is
most of the waiting in a reel.

The step coming into view now starts as soon as any of it is showing, and
silently, so it doesn't talk over the one still being watched; its own
sound setting is restored when it becomes the step being watched. A swipe
carried through therefore lands on a video that is already running.

A swipe that is abandoned leaves nothing behind: the step peeked at is
stopped and put back where the peek found it -- the start for a step never
watched, where it was left for one scrolled past -- so the next swipe to it
begins at the top rather than partway through something nobody saw.

"The scroll has stopped" has to mean stopped *on* a step. Scroll events also
stop while a finger holds the list between two of them, and the first cut
rewound the incoming video under the held finger -- which is exactly the
stall this removes. A settle that finds the scroller mid-step now waits for
the snap to land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 17:36:57 +00:00
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
Simon
1dbac33359 Recognise a favorite by its URL, and take the listing's copy of it
The same video reaches this client under two identities: saved from a
card it carries the server's id, imported from a Hot Tub backup it
carries only its URL. indexOfEntry already matched on either, but the
grid card and the player asked only whether the key was known -- so an
imported favorite left its own listing card, and the player, showing an
empty heart. All three surfaces now ask one question.

Once matched, the listing's copy is the better one: it has the id the
cards key on, and a thumbnail URL that hasn't been sitting in
localStorage since whenever the backup was taken. So a page of listing
videos rewrites the favorites it matches, keeping only the date each was
first saved -- the one fact the listing doesn't know, and the one the
sort depends on. Nothing is written when nothing differs.

Both identity sets now come from one cache dropped on write, since this
is read once per card built and per layout probe, and each read was
re-parsing the whole favorites list out of localStorage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-08 17:04:34 +00:00
43 changed files with 11711 additions and 549 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;
@@ -133,7 +139,8 @@ body.theme-light .top-bar {
.search-container input {
width: 100%;
padding: 10px 44px 10px 16px;
/* Room on the right for the clear button and the bookmark beside it. */
padding: 10px 78px 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 999px;
@@ -156,7 +163,7 @@ body.theme-light .top-bar {
.search-clear-btn {
position: absolute;
top: 50%;
right: 12px;
right: 44px;
transform: translateY(-50%);
width: 26px;
height: 26px;
@@ -185,6 +192,53 @@ body.theme-light .top-bar {
color: var(--text-primary);
}
/* Recent searches, under the box. The drawers' colours, but laid over the page
background rather than letting it through: this one hangs over thumbnails,
and a title showing through a suggestion is a title you try to read. */
.search-suggest {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: 120;
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: linear-gradient(var(--bg-secondary), var(--bg-secondary)), var(--bg-primary);
box-shadow: 0 18px 40px var(--shadow);
overflow: hidden;
}
.search-suggest-row {
display: block;
width: 100%;
padding: 9px 12px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.search-suggest-row:hover,
.search-suggest-row.is-active {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* The part that matched what is typed, so the eye can see why a row is here. */
.search-suggest-hit {
color: var(--accent);
font-weight: 600;
}
.actions {
display: flex;
gap: 12px;
@@ -1041,6 +1095,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 +1372,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 +1441,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 +1576,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; }
@@ -2138,6 +2236,27 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
height: 1px;
}
/* A turn goes through every shape between the one it left and the one it
lands in, and each of those re-letterboxes the picture somewhere else --
a run of jumps where there should be one. So for the length of the turn
the slides are held at the size it is heading for (feed.js sets the two
numbers): the picture moves once, straight into its final position and
size, and stays there while the viewport catches up to it. */
.feed-view.is-turning .feed-slide {
width: var(--turn-w);
height: var(--turn-h);
}
.feed-view.is-turning .feed-scroll {
/* The pinned slides are a different shape from the viewport until the turn
finishes, and none of that overflow is for scrolling through. Snapping
is off with it: the container would otherwise be re-anchoring on the old
height at the same time as the feed is pinning itself to the new one. */
overflow: hidden;
scroll-snap-type: none;
scroll-behavior: auto;
}
/* Stands in for the slides above the rendered window so scroll position and
snap points stay stable while slides are virtualized in and out. */
.feed-top-spacer {
@@ -2146,13 +2265,49 @@ 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.
The height is the scroller's own, not 100dvh: every scroll position in the
feed is computed as `index * scroller.clientHeight` (the top spacer stands
in for the slides above), so a slide that is even a few pixels off that
height makes the mapping drift by a slide's worth after a handful of them --
which is what used to make rotating the phone land on a different video. */
.feed-slide {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
height: 100%;
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 +2315,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 +2367,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 +2492,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 +2576,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 +2816,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); }
@@ -2627,3 +3088,400 @@ body.theme-light .video-card img:not(.is-loaded) {
pointer-events: none;
}
/* Privacy veil -- the page goes behind a blur whenever this window isn't the
one in front. Above everything else the app can put on screen, and painted
as a backdrop filter rather than a filter on the page itself, which would
make every fixed element inside it position against the wrong box. */
.privacy-veil {
position: fixed;
inset: 0;
z-index: 9000;
opacity: 0;
pointer-events: none;
background: rgba(10, 8, 6, 0.38);
-webkit-backdrop-filter: blur(26px) saturate(0.55);
backdrop-filter: blur(26px) saturate(0.55);
transition: opacity 200ms ease;
}
.privacy-veil.is-on {
opacity: 1;
/* Going behind the blur is immediate -- a fade would be a window during
which the page is still readable. Coming back can take its time. */
transition: none;
/* So the tap that brings the window back lands here and lifts the veil,
rather than pressing something on a page nobody could see. */
pointer-events: auto;
}
/* Nothing to blur through without backdrop-filter, so the veil covers instead. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.privacy-veil { background: var(--bg-primary); }
}
/* =====================================================================
Saved searches (see savedSearches.js + App.ui.savedSearches)
One hairline per row and no boxes inside boxes: the list is read as a
document, with the brass reserved for the one thing that is news -- how
many videos a saved search has picked up since it was last opened.
===================================================================== */
/* Line icons drawn in place rather than fetched: these sit inside buttons
that change colour with state, and a tinted <img> cannot follow. */
.icon-line {
width: 22px;
height: 22px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* --- The bookmark in the search bar ---------------------------------- */
.search-save-btn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
width: 30px;
height: 30px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.search-save-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.search-save-btn:active { transform: translateY(-50%) scale(0.94); }
.search-save-btn:disabled { opacity: 0.4; cursor: default; }
/* Saved: the glyph fills in, which is the whole state in one look. */
.search-save-btn.is-saved { color: var(--accent); }
.search-save-btn.is-saved .icon-line { fill: currentColor; stroke: currentColor; }
@keyframes saved-flash {
0% { transform: translateY(-50%) scale(1); }
35% { transform: translateY(-50%) scale(1.22); }
100% { transform: translateY(-50%) scale(1); }
}
.search-save-btn.is-just-saved { animation: saved-flash 0.42s cubic-bezier(0.2, 0.7, 0.2, 1); }
/* --- The header button and its count --------------------------------- */
.saved-toggle { position: relative; }
.saved-badge {
position: absolute;
top: 6px;
right: 5px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--accent);
color: var(--bg-primary);
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px rgba(20, 17, 13, 0.9);
}
body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); }
/* --- The list ---------------------------------------------------------- */
.saved-panel {
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);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.saved-panel.open { display: flex; }
.saved-box {
display: flex;
flex-direction: column;
width: min(620px, 100%);
max-height: min(78vh, 760px);
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;
}
.saved-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 14px 20px;
}
.saved-head h3 {
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.015em;
}
.saved-head-note {
margin-top: 4px;
max-width: 44ch;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
}
.saved-head-actions {
display: flex;
align-items: center;
/* Two underlined links side by side need the space to read as two. */
gap: 12px;
flex-shrink: 0;
}
.saved-list {
flex: 1;
overflow-y: auto;
padding: 0 10px;
overscroll-behavior: contain;
}
@keyframes saved-row-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.saved-row {
position: relative;
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--border);
}
/* Rows animate in when they arrive, and only then: a row already on screen
that a background check wrote a new count into has not arrived anywhere. */
.saved-row.is-new {
animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: calc(var(--i, 0) * 40ms);
}
.saved-list .saved-row:last-child { border-bottom: none; }
.saved-row-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 10px;
border: none;
border-radius: var(--radius-md);
background: none;
color: var(--text-primary);
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.18s ease;
}
.saved-row-main:hover { background: var(--bg-tertiary); }
.saved-row-main:disabled { cursor: default; }
/* The count. Brass and filled when there is something new; a hairline ring
and a single dot when there isn't, so an up-to-date row reads as quiet
rather than as a zero. */
.saved-count {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid var(--border-hover);
background: rgba(201, 165, 103, 0.16);
color: var(--accent);
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
justify-content: center;
}
.saved-count.is-quiet {
border-color: var(--border);
background: transparent;
color: var(--text-secondary);
}
.saved-row.is-unreachable .saved-count { border-style: dashed; }
.saved-row-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.saved-row-name {
font-family: var(--font-display);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.saved-row-meta {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.saved-row-stamp {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-secondary);
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.saved-row-tools {
display: flex;
gap: 2px;
flex-shrink: 0;
padding-right: 6px;
opacity: 0;
transition: opacity 0.18s ease;
}
.saved-row:hover .saved-row-tools,
.saved-row:focus-within .saved-row-tools { opacity: 1; }
.saved-tool {
width: 32px;
height: 32px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.saved-tool .icon-line { width: 18px; height: 18px; }
.saved-tool:hover { color: var(--accent); background: var(--bg-tertiary); border-color: var(--border); }
.saved-rename {
width: 100%;
padding: 5px 9px;
background: var(--bg-tertiary);
border: 1px solid var(--border-hover);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-display);
font-size: 15px;
}
.saved-rename:focus { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); }
/* Removing asks first, in the row itself -- no dialog on top of a dialog. */
.saved-confirm {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-right: 8px;
}
.saved-confirm-text {
font-size: 12px;
color: var(--text-secondary);
}
.saved-confirm-btn {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: none;
color: var(--text-primary);
font-family: inherit;
font-size: 12px;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.saved-confirm-btn:hover { background: var(--bg-tertiary); border-color: var(--border-hover); }
.saved-confirm-btn.is-danger { color: #e2a2a2; }
body.theme-light .saved-confirm-btn.is-danger { color: #9f2f2d; }
.saved-confirm-btn.is-danger:hover { border-color: currentColor; }
.saved-row.is-confirming .saved-row-main { opacity: 0.5; }
.saved-empty {
padding: 4px 20px 20px 20px;
max-width: 52ch;
font-size: 13px;
line-height: 1.65;
color: var(--text-secondary);
}
.saved-foot {
padding: 14px 20px 18px 20px;
border-top: 1px solid var(--border);
}
.saved-add { width: 100%; }
.saved-add:disabled { opacity: 0.55; cursor: default; }
@media (max-width: 720px) {
.saved-panel { padding: 0; }
.saved-box {
width: 100%;
height: 100%;
max-height: 100%;
border: none;
border-radius: 0;
}
/* Nothing to hover with, so the row's tools are simply there. */
.saved-row-tools { opacity: 1; }
.saved-row-stamp { white-space: normal; }
}
@media (pointer: coarse) {
.saved-row-tools { opacity: 1; }
.search-save-btn { width: 34px; height: 34px; }
}

View File

@@ -15,13 +15,30 @@
<header class="top-bar">
<div class="logo">Jacuzzi</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Search videos...">
<input type="text" id="search-input" placeholder="Search videos..."
autocomplete="off" role="combobox" aria-expanded="false"
aria-controls="search-suggest" aria-autocomplete="list">
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
<button class="search-save-btn" id="search-save-btn" type="button" aria-pressed="false"
aria-label="Save this search" title="Save this search">
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6.5 4.75h11a.75.75 0 0 1 .75.75v13.3l-6.25-4.1-6.25 4.1V5.5a.75.75 0 0 1 .75-.75z"/>
</svg>
</button>
<div id="search-suggest" class="search-suggest" role="listbox" aria-label="Recent searches" hidden></div>
</div>
<div class="actions">
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
</button>
<button class="icon-btn saved-toggle" id="saved-searches-btn" type="button"
title="Saved searches" aria-label="Saved searches" aria-haspopup="dialog" aria-expanded="false">
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 3.75h8a1 1 0 0 1 1 1V17l-5-3.2L7 17V4.75a1 1 0 0 1 1-1z"/>
<path d="M18.5 6.5h.5a1 1 0 0 1 1 1v12.75l-3-2" stroke-opacity="0.55"/>
</svg>
<span class="saved-badge" id="saved-searches-badge" aria-hidden="true" hidden></span>
</button>
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
</button>
@@ -58,8 +75,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>
@@ -137,6 +163,14 @@
</div>
<div id="sources-list" class="sources-list"></div>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Captured Thumbnails</h4>
<div class="setting-item">
<label for="clear-posters-btn">Kept Pictures</label>
<button id="clear-posters-btn" class="btn-secondary" type="button">Take them again</button>
<p id="clear-posters-status" class="setting-note" role="status" aria-live="polite"></p>
</div>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
<div class="setting-item">
@@ -175,7 +209,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 +231,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">
@@ -201,6 +251,32 @@
</div>
</div>
<div id="saved-panel" class="saved-panel" aria-hidden="true">
<div class="saved-box" role="dialog" aria-modal="true" aria-labelledby="saved-panel-title">
<div class="saved-head">
<div class="saved-head-text">
<h3 id="saved-panel-title">Saved searches</h3>
<p class="saved-head-note" id="saved-head-note"></p>
</div>
<div class="saved-head-actions">
<button id="saved-read-btn" class="btn-link" type="button">Mark all read</button>
<button id="saved-check-btn" class="btn-link" type="button">Check now</button>
<button id="saved-close-btn" class="close-btn" type="button" aria-label="Close">✕</button>
</div>
</div>
<div id="saved-list" class="saved-list"></div>
<div id="saved-empty" class="saved-empty">
Nothing saved yet. Pick a channel, set your filters, type a search —
then press the bookmark in the search bar to keep the whole thing.
</div>
<div class="saved-foot">
<button id="saved-add-btn" class="btn-secondary saved-add" type="button">Save the current search</button>
</div>
</div>
</div>
<div id="privacy-veil" class="privacy-veil" aria-hidden="true"></div>
<script src="static/js/state.js"></script>
<script src="static/js/marquee.js"></script>
<script src="static/js/storage.js"></script>
@@ -208,13 +284,16 @@
<script src="static/js/player.js"></script>
<script src="static/js/favorites.js"></script>
<script src="static/js/favoritesView.js"></script>
<script src="static/js/posters.js"></script>
<script src="static/js/sqlite.js"></script>
<script src="static/js/hottubBackup.js"></script>
<script src="static/js/videos.js"></script>
<script src="static/js/savedSearches.js"></script>
<script src="static/js/feed.js"></script>
<script src="static/js/ui.js"></script>
<script src="static/js/version.js"></script>
<script src="static/js/enhance.js"></script>
<script src="static/js/privacy.js"></script>
<script src="static/js/main.js"></script>
</body>
</html>

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');
@@ -171,6 +185,34 @@ App.enhance = App.enhance || {};
});
});
}
// Saved searches: the list itself, the one-press save, and every
// saved search as a row of its own so ⌘K can jump straight to one.
if (App.savedSearches && App.ui && App.ui.savedSearches) {
const saved = App.savedSearches.getAll();
out.push({ label: 'Saved searches', hint: saved.length ? `${saved.length} saved` : 'Nothing saved yet', run: () => App.ui.savedSearches.open() });
const match = App.savedSearches.findMatching();
out.push({
label: match ? `Remove saved search: ${match.name}` : 'Save the current search',
hint: 'Source · channel · filters · query',
run: () => App.ui.savedSearches.toggleCurrent()
});
const total = App.savedSearches.totalNew();
if (total) {
out.push({
label: 'Mark all saved searches read',
hint: `${App.savedSearches.badgeLabel(total)} new`,
run: () => App.savedSearches.markAllRead()
});
}
saved.forEach((entry) => {
const label = App.savedSearches.badgeLabel(entry.newCount);
out.push({
label: entry.name,
hint: label ? `Saved search · ${label} new` : 'Saved search',
run: () => App.savedSearches.apply(entry.id)
});
});
}
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
@@ -182,11 +224,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

@@ -4,6 +4,12 @@ App.favorites = App.favorites || {};
(function() {
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
// Both identities of every favorite, in one pass, held until the list
// changes (see setAll). This is read once per card built, and re-parsing the
// whole favorites list out of localStorage that often is what makes a long
// list felt on a scrolling grid.
let identityCache = null;
// Favorites storage helpers.
App.favorites.getAll = function() {
try {
@@ -87,6 +93,7 @@ App.favorites = App.favorites || {};
};
App.favorites.setAll = function(items) {
identityCache = null;
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
};
@@ -181,19 +188,49 @@ App.favorites = App.favorites || {};
return { added, skipped, total: favorites.length };
};
const identities = function() {
if (!identityCache) {
const keys = new Set();
const urls = new Set();
// getAll may rewrite the list (the repair pass), which clears the
// cache -- so build it only after that has run.
const items = App.favorites.getAll();
items.forEach((item) => {
if (!item) return;
if (item.key) keys.add(item.key);
const urlKey = App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
identityCache = { keys: keys, urls: urls };
}
return identityCache;
};
App.favorites.getSet = function() {
return new Set(App.favorites.getAll().map((item) => item.key));
return identities().keys;
};
// Same set, addressed by URL. Imported favorites are keyed by URL rather
// than by a server id, so a listing card can only recognise one this way.
App.favorites.getUrlSet = function() {
const urls = new Set();
App.favorites.getAll().forEach((item) => {
const urlKey = item && App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
return urls;
return identities().urls;
};
// Is this video a favorite, under either identity? A video reaches us from
// the listing keyed by the server's id and from a Hot Tub backup keyed by
// its URL, and the same video must light up its heart whichever way the
// copy on disk got there.
App.favorites.has = function(video) {
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));
};
// Is this video already a favorite, whichever way it got saved? Checked by
@@ -209,6 +246,58 @@ App.favorites = App.favorites || {};
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
};
// Everything an entry carries, so a change can be told from a no-op.
const ENTRY_FIELDS = ['key', 'id', 'url', 'title', 'thumb', 'channel',
'uploader', 'duration', 'isLive', 'favoriteDate'];
const sameEntry = function(a, b) {
return ENTRY_FIELDS.every((field) =>
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
String(b[field] === undefined || b[field] === null ? '' : b[field]));
};
// Brings stored favorites up to date from a page of listing videos.
//
// The same video reaches this client under two identities: saved from a
// card it carries the server's id, imported from a Hot Tub backup it
// carries only its URL. Matching on the URL is what recognises them as one
// thing -- and once they are matched, the listing's copy is the better one.
// It has the id every card keys on, and a thumbnail URL that hasn't been
// sitting in localStorage since whenever the backup was taken. So the
// stored entry is replaced by it, keeping only the date it was first saved:
// that is the one fact the listing doesn't know and the favorites sort
// depends on.
//
// Writes only when something actually differs, so the steady state of
// scrolling a listing full of favorites is no writes at all.
App.favorites.reconcile = function(videos) {
const items = Array.isArray(videos) ? videos : [];
if (!items.length) return 0;
const favorites = App.favorites.getAll();
if (!favorites.length) return 0;
let changed = 0;
items.forEach((video) => {
if (!video || !video.url) return;
const index = App.favorites.indexOfEntry(favorites, video);
if (index < 0) return;
const existing = favorites[index];
const upgraded = App.favorites.normalize(Object.assign({}, video, {
favoriteDate: existing.favoriteDate
}));
if (!upgraded || sameEntry(existing, upgraded)) return;
favorites[index] = upgraded;
changed++;
});
if (changed) {
App.favorites.setAll(favorites);
App.favorites.renderBar();
App.favorites.syncButtons();
}
return changed;
};
App.favorites.isVisible = function() {
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
};
@@ -405,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), item);
}
card.onclick = () => {
if (card.classList.contains('is-loading')) return;

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,10 @@ window.App = window.App || {};
(function() {
// App bootstrap: initialize storage, render UI, and load the first page.
async function initApp() {
// Armed before anything is on screen: there is nothing to show behind
// a blur yet, but the window can lose focus during startup too.
App.privacy.start();
await App.storage.ensureDefaults();
App.ui.applyTheme();
App.ui.applyPreferredQuality();
@@ -39,6 +43,12 @@ window.App = window.App || {};
await App.videos.loadVideos();
App.favorites.syncButtons();
// The grid is showing something now. If that something is one of the
// saved searches, the reader is looking at it -- so it stops being
// "new" from here, and the once-a-day check starts running.
App.savedSearches.syncViewed();
App.savedSearches.start();
// The UI above is rendered entirely from the last known status cached in
// localStorage, so startup never blocks on (or breaks because of) a slow
// or failing status endpoint. Now fetch fresh status in the background and

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 */ } });
@@ -295,7 +330,7 @@ App.player = App.player || {};
const key = App.favorites.getKey(videoData);
if (!key) { btn.hidden = true; return; }
btn.dataset.favKey = key;
App.favorites.setButtonState(btn, App.favorites.getSet().has(key));
App.favorites.setButtonState(btn, App.favorites.has(videoData));
const onClick = (event) => {
event.stopPropagation();
App.favorites.toggle(videoData);
@@ -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))) || '';
@@ -734,7 +830,7 @@ App.player = App.player || {};
// a browser, so there is nothing to find out.
if (resolved.refererRequired) return;
if (directProven(resolved.url)) return;
App.videos.probeDirect(resolved.url).then((ok) => {
App.videos.probeDirect(resolved).then((ok) => {
if (!ok || token !== cp.attemptToken) return;
// readyState >= HAVE_CURRENT_DATA means a frame is up; leave a
// playing video alone rather than trading a visible stall for a
@@ -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;

583
frontend/js/posters.js Normal file
View File

@@ -0,0 +1,583 @@
window.App = window.App || {};
App.posters = App.posters || {};
// ---------------------------------------------------------------------------
// Captured posters
//
// A favorite outlives its thumbnail. The picture a listing hands out is a
// signed CDN path on most sources, and the signature expires -- so a video kept
// months ago has a `thumb` that 404s, and the card that stood for it is a grey
// box with a title under it. Asking the source page for a fresh one (the
// /api/poster repair in videos.js) covers the case where the page still exists
// and still shows a picture; nothing covers the case where it doesn't.
//
// The video itself does, though: it is still playable, because playback
// re-resolves from the page URL every time. So once every route to the listed
// picture is exhausted, a frame a quarter of the way into the clip becomes the
// card's picture -- far enough in to be past a title card or a black lead-in,
// and a fair guess at what the video looks like.
//
// A frame costs a resolve and the stretch of video around that point, which is
// why this happens once per item, for favorites only, and never again: the
// frame is scaled down, JPEG'd, and kept in localStorage, so the next mount and
// every later session paint it straight away without touching the network.
//
// Which is also why a capture has to be looked at before it is kept. A frame
// with nothing in it -- an element that decoded nothing draws as nothing, and a
// lead-in or a fade is black in its own right -- is a black box on the card for
// the life of the item, and asking again is exactly what the cache is there to
// avoid. So a blank frame is taken somewhere else in the clip instead (see
// planFor), and one kept by a version of this that did not check gets measured
// once and replaced (see recheck).
// ---------------------------------------------------------------------------
(function() {
const INDEX_KEY = App.constants.POSTER_CACHE_KEY;
const ENTRY_PREFIX = `${INDEX_KEY}:`;
// Marks an entry whose picture was checked for having a picture in it,
// which every capture now is and none of the older ones were.
const CHECKED = 1;
// A thumbnail box is around 300 CSS px wide in the grid and half a phone
// screen in the favorites bar, so this is a picture at roughly card size
// and not a frame of video: the point of the cache is to be small.
const WIDTH = 360;
const QUALITY = 0.72;
// How far into the clip the frame comes from.
const SEEK_FRACTION = 0.25;
// What the whole cache may take. Stored base64, so the string is a third
// larger than the JPEG behind it -- around 20 KB an entry, which leaves
// room for a hundred or so of them inside a localStorage budget that is
// shared with the favorites themselves.
const BUDGET_BYTES = 3 * 1024 * 1024;
// How long a seek gets before the frame it was decoding anyway will do.
const SEEK_PATIENCE_MS = 6000;
// How long a frame gets to be presented once the data for it has arrived.
const PRESENT_PATIENCE_MS = 500;
// A capture is a handful of seeks into one file. If none of them has
// produced a picture by now, the source is not going to give us one.
const FRAME_TIMEOUT_MS = 30000;
// How bright the brightest sample in a frame has to be for the frame to be
// a picture of anything at all. A floor, not a threshold: this is the
// difference between a frame and no frame, not between a dark scene and a
// bright one.
const BLACK_LEVEL = 10;
// Every fourth pixel answers that question, and keeps the check off the
// cost of a capture.
const SAMPLE_STRIDE = 4;
// Where to look in a clip whose length nobody will say.
const BLIND_MARK_SECONDS = 10;
// How many of the ranked sources are worth trying before giving up on an
// item for this session.
const SOURCE_TRIES = 2;
// Both halves of a 64-bit-ish hash of the item's identity: a cache key has
// to be short enough to be a localStorage key and distinct enough that two
// favorites never share a picture.
const hash = function(text) {
let fnv = 0x811c9dc5;
let djb = 5381;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
fnv = (fnv ^ code) >>> 0;
fnv = (fnv + ((fnv << 1) + (fnv << 4) + (fnv << 7) + (fnv << 8) + (fnv << 24))) >>> 0;
djb = (((djb << 5) + djb) + code) >>> 0;
}
return fnv.toString(16).padStart(8, '0') + djb.toString(16).padStart(8, '0');
};
// The identity to cache under: the item's own URL, normalized the way
// favorites normalize one, so the same video saved from a listing and
// imported from a backup lands on the same picture.
const keyOf = function(video) {
if (!video || typeof video !== 'object') return '';
const url = video.url || (video.meta && video.meta.url) || '';
const basis = (url && App.favorites && App.favorites.urlKey(url)) || url ||
(App.favorites && App.favorites.getKey(video)) || video.id || '';
return basis ? hash(String(basis)) : '';
};
// ---- the cache -------------------------------------------------------
// [{ k, n, at }], oldest capture first. Read once and kept: a card asks
// whether it has a picture as it mounts, and parsing the index again for
// every card of a scrolling grid is the one thing this must not cost.
let index = null;
const readIndex = function() {
if (index) return index;
try {
const parsed = JSON.parse(localStorage.getItem(INDEX_KEY));
index = Array.isArray(parsed) ?
parsed.filter((entry) => entry && entry.k).map((entry) => ({
k: String(entry.k),
n: Number(entry.n) || 0,
at: Number(entry.at) || 0,
// Whether anything ever looked at what this entry holds.
// Absent on everything written before that was checked --
// see recheck.
v: Number(entry.v) || 0
})) : [];
} catch (err) {
index = [];
}
return index;
};
const writeIndex = function() {
try {
localStorage.setItem(INDEX_KEY, JSON.stringify(index));
} catch (err) { /* nothing here is worth failing a card over */ }
};
const drop = function(entry) {
try {
localStorage.removeItem(ENTRY_PREFIX + entry.k);
} catch (err) { /* already gone */ }
};
const store = function(key, src) {
const list = readIndex().filter((entry) => entry.k !== key);
list.push({ k: key, n: src.length, at: Date.now(), v: CHECKED });
index = list;
// Oldest captures go first. Not least-recently-seen: tracking that
// would mean writing to storage every time a card mounts, which is a
// worse trade than occasionally re-capturing a picture.
let total = list.reduce((sum, entry) => sum + entry.n, 0);
while (total > BUDGET_BYTES && index.length > 1) {
const oldest = index.shift();
total -= oldest.n;
drop(oldest);
}
try {
localStorage.setItem(ENTRY_PREFIX + key, src);
} catch (err) {
// Out of room, and not necessarily because of us -- the store is
// shared with the favorites and everything else. Make room by
// giving up the oldest third of the cache and try once more.
const giveUp = Math.max(1, Math.floor(index.length / 3));
index.splice(0, giveUp).forEach(drop);
try {
localStorage.setItem(ENTRY_PREFIX + key, src);
} catch (again) {
index = index.filter((entry) => entry.k !== key);
writeIndex();
return '';
}
}
writeIndex();
return src;
};
const read = function(key) {
try {
return localStorage.getItem(ENTRY_PREFIX + key) || '';
} catch (err) {
return '';
}
};
// The picture already captured for this item, if there is one.
App.posters.cached = function(video) {
const key = keyOf(video);
if (!key) return '';
// The index says what we have without touching the entries, so an item
// with no captured picture -- almost all of them -- costs a set lookup
// rather than a read of a data URL out of storage.
if (!readIndex().some((entry) => entry.k === key)) return '';
const src = read(key);
if (!src) {
index = readIndex().filter((entry) => entry.k !== key);
writeIndex();
}
return src;
};
App.posters.forget = function(video) {
const key = keyOf(video);
if (!key) return;
readIndex().filter((entry) => entry.k === key).forEach(drop);
index = readIndex().filter((entry) => entry.k !== key);
writeIndex();
};
// Throws every captured picture away, and with it this session's memory of
// which items there was no point asking about. That memory is the whole
// reason a dead favorite is cheap to scroll past, so it only goes when the
// reader asks for the pictures to be taken again (Settings -> Captured
// Thumbnails) -- otherwise an item that failed once would be retried on
// every mount for the rest of the session.
App.posters.clear = function() {
readIndex().forEach(drop);
index = [];
writeIndex();
failed.clear();
rechecks.clear();
};
App.posters.stats = function() {
const list = readIndex();
return { count: list.length, bytes: list.reduce((sum, entry) => sum + entry.n, 0) };
};
// ---- taking the frame ------------------------------------------------
// Is there a picture in what was just drawn?
//
// Two things come out of a capture looking identical, and both are useless.
// A video that has decoded nothing draws as nothing: the canvas keeps its
// transparent black, which becomes a pure black JPEG about 1.6 KB long --
// large enough to pass for a picture, and kept for the life of the item.
// And a frame that genuinely is black (a lead-in, a fade, a cut between
// scenes) is no better a thumbnail than that one. The answer to both is to
// take the frame somewhere else, so they are the same question.
const isBlank = function(ctx, width, height) {
// Tainted canvas throws here, which is why the frame is taken from our
// own proxy rather than from the CDN: /api/stream is same-origin, so
// the pixels can be read back.
const px = ctx.getImageData(0, 0, width, height).data;
let painted = false;
let brightest = 0;
for (let i = 0; i < px.length; i += 4 * SAMPLE_STRIDE) {
if (px[i + 3] > 0) painted = true;
const value = Math.max(px[i], px[i + 1], px[i + 2]);
if (value > brightest) brightest = value;
}
return !painted || brightest <= BLACK_LEVEL;
};
// Draws what the element is showing, at card size rather than video size.
// Empty when there turns out to be nothing in it, which is the caller's cue
// to look elsewhere in the clip rather than keep it.
const drawFrame = function(el) {
const width = el.videoWidth;
const height = el.videoHeight;
if (!width || !height) throw new Error('nothing was decoded');
const scale = Math.min(1, WIDTH / width);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext('2d');
ctx.drawImage(el, 0, 0, canvas.width, canvas.height);
if (isBlank(ctx, canvas.width, canvas.height)) return '';
const src = canvas.toDataURL('image/jpeg', QUALITY);
if (!src || src.length < 512) throw new Error('the frame came out empty');
return src;
};
// Where a frame comes from, and what to do when it comes back blank. A
// blank frame means one of two things and each has its own answer: the
// element decoded nothing (so play it for a moment -- an element that is
// playing has to present frames, which a seeked one is under no obligation
// to do), or the frame at that mark really is black (so take it somewhere
// else).
const planFor = function(length) {
if (length > 0 && isFinite(length)) {
return [
{ at: length * SEEK_FRACTION, play: false },
{ at: length * SEEK_FRACTION, play: true },
{ at: length * 0.5, play: true }
];
}
// Nobody will say how long this is: an extractor that reports no
// duration, a file the browser won't commit to a length for. A blind
// seek to a fixed mark still beats the opening frame, which is a black
// one often enough to be half the reason this check exists -- and a
// mark past the end comes back clamped into the file, so the guess
// costs nothing.
return [
{ at: BLIND_MARK_SECONDS, play: false },
{ at: 0, play: true }
];
};
const grabFrame = function(url, duration) {
return new Promise((resolve, reject) => {
const el = document.createElement('video');
el.muted = true;
el.defaultMuted = true;
el.playsInline = true;
el.preload = 'auto';
el.crossOrigin = 'anonymous';
el.setAttribute('aria-hidden', 'true');
// Off-screen, but in the document: an element the browser does not
// consider part of a page is under no obligation to decode
// anything.
el.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;' +
'opacity:0;pointer-events:none';
let settled = false;
let budget = null;
let patience = null;
// The marks to try, and which of them we are on.
let plan = [];
let step = -1;
// Where we asked to be, and whether we asked at all. A browser
// fires `seeked` for its own initial seek to zero as the file
// opens, and taking the frame on that one would mean every
// captured picture is the first frame of the video -- usually a
// black one -- however far in we meant to go.
let target = 0;
let asked = false;
// A frame is already being waited for. A completed seek and the
// patience timer can both decide it is time, and the second one to
// decide must not start a second attempt on the same frame.
let pending = false;
const done = function(err, src) {
if (settled) return;
settled = true;
if (budget) clearTimeout(budget);
if (patience) clearTimeout(patience);
try { el.pause(); } catch (stop) { /* never started */ }
// Told to stop downloading before it leaves, or the request
// outlives the element that made it.
el.removeAttribute('src');
try { el.load(); } catch (stop) { /* nothing to stop */ }
el.remove();
if (err) reject(err);
else resolve(src);
};
const take = function() {
pending = false;
let src = '';
try {
src = drawFrame(el);
} catch (err) {
done(err);
return;
}
if (src) {
done(null, src);
return;
}
// Nothing in it, so this mark is spent.
attempt();
};
// Drawing an element that has not presented a frame gives an empty
// canvas, and the engines disagree about when that is: Chromium has
// a decoded frame by the time it says a seek is done, while Safari
// will not present one for an off-screen paused element until it
// has to. `requestVideoFrameCallback` is the browser saying a frame
// is on screen now, which is the only direct answer there is; where
// there is none, a decoded readyState after a paint is the nearest
// thing to one.
const whenPresented = function() {
if (settled || pending) return;
pending = true;
let taken = false;
const once = function() {
if (taken || settled) return;
taken = true;
take();
};
if (typeof el.requestVideoFrameCallback === 'function') {
el.requestVideoFrameCallback(() => once());
// An element that is paused on the frame it already
// presented may never present another, so the callback on
// its own would wait for good.
setTimeout(() => {
if (el.readyState >= 2) once();
else pending = false;
}, PRESENT_PATIENCE_MS);
return;
}
requestAnimationFrame(() => {
if (el.readyState >= 2) once();
else setTimeout(once, PRESENT_PATIENCE_MS);
});
};
const attempt = function() {
step += 1;
if (step >= plan.length) {
done(new Error('every frame came out blank'));
return;
}
if (patience) clearTimeout(patience);
const mark = plan[step];
target = mark.at;
if (mark.play) {
const playing = el.play();
if (playing && typeof playing.catch === 'function') playing.catch(() => {});
}
if (target > 0 && Math.abs(el.currentTime - target) > 1) {
asked = true;
try { el.currentTime = target; } catch (err) { done(err); return; }
} else {
// Already where this attempt wanted to be: all that has
// changed is that the element is now playing.
whenPresented();
}
// Some sources cannot be seeked in at all: no byte ranges, or
// a length the browser does not trust. They still have an
// opening frame, and a picture of the opening beats a grey box
// -- but only once the seek has had its chance.
patience = setTimeout(() => {
if (el.readyState >= 2) whenPresented();
}, SEEK_PATIENCE_MS);
};
budget = setTimeout(() => done(new Error('no frame in time')), FRAME_TIMEOUT_MS);
el.addEventListener('error', () => done(new Error('the source would not play')));
el.addEventListener('loadedmetadata', () => {
plan = planFor(Number(el.duration) || Number(duration) || 0);
step = -1;
attempt();
});
el.addEventListener('seeked', () => {
// Not ours: the browser's own seek to zero as the file opens.
if (!asked || (target > 0 && el.currentTime <= 0)) return;
// Near enough: a seek lands on the nearest frame it can decode,
// which is the keyframe before the mark rather than the mark
// itself, and a mark past the end comes back clamped to as
// close as that file gets.
whenPresented();
});
document.body.appendChild(el);
el.src = url;
});
};
const captureOne = async function(video) {
// Favorites keep no resolved formats on purpose (their URLs expire), so
// this is where the item becomes playable again.
try {
await App.videos.ensureFormats(video);
} catch (err) { /* the page-URL fallback below may still play */ }
const sources = App.videos.resolveStreamSources(video, {
applyPreferredQuality: false,
// The smallest format there is: this is going to end up 360 px
// wide, so a 1080p stream would be bytes spent on pixels that get
// thrown away.
cheapest: true
}).filter((source) => {
// A manifest is a playlist of segments, not something a bare
// <video> can open outside Safari, and the player's hls.js is a
// heavier thing than a thumbnail is worth.
const kind = App.videos.classifySource(source);
return !kind.isHls && !source.isLive;
}).slice(0, SOURCE_TRIES);
for (let i = 0; i < sources.length; i++) {
const url = App.videos.buildStreamUrlFromSource(sources[i]);
if (!url) continue;
try {
return await grabFrame(url, Number(video.duration) || 0);
} catch (err) {
// Try the next format; if that was the last one, the item has
// no picture and no amount of asking changes it.
}
}
return '';
};
// The same item is on screen in more than one place -- the favorites bar
// and the grid behind it, a card and the reel's own poster -- and all of
// them ask. They wait on the one capture and all get the picture; the
// alternative is the first one to ask getting a picture and the rest being
// told there isn't one.
const inFlight = new Map();
// And one try per item per session once it has come back empty: a failed
// capture is a resolve plus a download, and a grid that scrolls past the
// same dead favorite ten times must not pay for it ten times.
const failed = new Set();
// One capture at a time. The reader is watching something; a thumbnail has
// no business competing with it for bandwidth.
let queue = Promise.resolve();
App.posters.capture = function(video) {
const key = keyOf(video);
if (!key) return Promise.resolve('');
const have = App.posters.cached(video);
if (have) return Promise.resolve(have);
const running = inFlight.get(key);
if (running) return running;
if (failed.has(key)) return Promise.resolve('');
// A live stream has no quarter mark, and whatever it is showing now is
// not what it will be showing when the card is next looked at.
if (video.isLive || (video.meta && video.meta.isLive)) return Promise.resolve('');
const run = queue.then(() => captureOne(video)).then((src) => {
inFlight.delete(key);
if (!src) {
failed.add(key);
return '';
}
return store(key, src);
}, () => {
inFlight.delete(key);
failed.add(key);
return '';
});
inFlight.set(key, run);
queue = run.catch(() => '');
return run;
};
// ---- pictures kept before captures were checked ----------------------
// Decodes a kept picture and says whether there is one in it -- the same
// test a fresh capture passes, run on the stored JPEG rather than on a
// frame of video.
const hasPicture = function(src) {
return new Promise((resolve) => {
const probe = new Image();
probe.onload = function() {
const canvas = document.createElement('canvas');
canvas.width = probe.naturalWidth || 1;
canvas.height = probe.naturalHeight || 1;
const ctx = canvas.getContext('2d');
ctx.drawImage(probe, 0, 0);
try {
resolve(!isBlank(ctx, canvas.width, canvas.height));
} catch (err) {
// No way to tell, so leave it where it is.
resolve(true);
}
};
probe.onerror = () => resolve(false);
probe.src = src;
});
};
// One measurement per item per session, however many cards show it -- and
// all of them wait on that one and get the replacement. The same item is on
// screen in more than one place (the favorites bar and the grid behind it),
// so the first asker getting the picture and the rest being told the kept
// one stands would leave the black box up on every card but one.
const rechecks = new Map();
// A picture kept by a version of this that did not check its captures can
// be a frame of nothing -- pure black, and kept for the life of the item,
// since the only test it had to pass was being a few hundred bytes long.
// Each unchecked entry is measured once, after the card has already painted
// what there is, and one with nothing in it is dropped and taken again.
// Resolves with a replacement picture, or '' when the kept one stands.
App.posters.recheck = function(video) {
const key = keyOf(video);
if (!key) return Promise.resolve('');
const running = rechecks.get(key);
if (running) return running;
const entry = readIndex().find((item) => item.k === key);
if (!entry || entry.v) return Promise.resolve('');
const src = read(key);
if (!src) return Promise.resolve('');
const run = hasPicture(src).then((keep) => {
if (keep) {
// Nothing wrong with it. Said so, so no later session measures
// this one again.
entry.v = CHECKED;
writeIndex();
return '';
}
App.posters.forget(video);
return App.posters.capture(video);
}, () => '');
rechecks.set(key, run);
return run;
};
})();

60
frontend/js/privacy.js Normal file
View File

@@ -0,0 +1,60 @@
window.App = window.App || {};
App.privacy = App.privacy || {};
(function() {
// The moment this window stops being the one in front -- another tab,
// another window, another app -- the page goes behind a blur. What the site
// is showing is nobody else's business: not a glance over the shoulder, not
// the app switcher's snapshot, not a screen share of the tab next door.
const ON = 'is-on';
let veil = null;
let veiled = false;
const element = function() {
if (!veil || !veil.isConnected) veil = document.getElementById('privacy-veil');
return veil;
};
const setVeiled = function(next) {
const el = element();
if (!el || next === veiled) return;
veiled = next;
el.classList.toggle(ON, next);
};
const cover = function() { setVeiled(true); };
const uncover = function() { setVeiled(false); };
function start() {
if (!element()) return;
// Leaving. Any one of these can be the last event before the window is
// out of sight, so each covers on its own, and covers at once: a blur
// that lands after the app switcher took its picture is no blur at all.
window.addEventListener('blur', cover);
window.addEventListener('pagehide', cover);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') cover();
else uncover();
});
// Coming back.
window.addEventListener('focus', uncover);
window.addEventListener('pageshow', uncover);
// A backstop, for whatever the browser did or didn't report: someone
// using the page is someone looking at it. The first touch lands on the
// veil and lifts it instead of reaching the thing underneath, which is
// the right way round for a tap that was meant for a page you could see.
['pointerdown', 'touchstart', 'keydown', 'wheel'].forEach(function(name) {
window.addEventListener(name, uncover, { capture: true, passive: true });
});
}
App.privacy = {
start: start,
cover: cover,
uncover: uncover,
isVeiled: function() { return veiled; }
};
})();

View File

@@ -0,0 +1,502 @@
window.App = window.App || {};
App.savedSearches = App.savedSearches || {};
// ---------------------------------------------------------------------------
// Saved searches
//
// A search here is more than the words in the box: it is the source, the
// channel, every filter that channel offers, and the query together. Set all
// that up once -- "uncensored JAV, newest first, 1080p and up" -- and this
// keeps it, so getting back to it is one tap instead of five.
//
// Each saved search also remembers what was on screen the last time it was
// opened (the ids of the newest videos it showed). Once a day the client asks
// each one's first page again and counts how many of those videos it has never
// shown the reader, which is the number on the badge. Nothing is counted
// server-side and nothing leaves the device but the same listing request the
// grid would have made anyway.
// ---------------------------------------------------------------------------
(function() {
const KEY = App.constants.SAVED_SEARCHES_KEY;
// How many of the newest ids are kept as the "already seen" mark. Several
// times the page asked for below: `perPage` is a request, not a promise --
// sources answer a page of 12 with 30 whenever that is how their own pages
// are cut -- so the mark has to be able to hold a page larger than the one
// that was asked for, and then some, for a listing that shifts between
// visits to still be measured against something.
const SEEN_KEEP = 60;
// One page is enough to answer "is there anything new, and roughly how
// much": the badge stops counting at 9+ anyway.
const PROBE_PER_PAGE = 12;
// A group asks each of its channels, so each one asks for less.
const GROUP_PROBE_PER_PAGE = 4;
// Above this the badge says "9+" rather than a number.
const NEW_CAP = 9;
const DAY_MS = 24 * 60 * 60 * 1000;
// How often a tab left open gets round to asking again. The check itself
// is still once a day per search; this is just how often we look.
const HEARTBEAT_MS = 30 * 60 * 1000;
// Checks run together, but not all of them: a reader with twenty saved
// searches should not open twenty listing requests on startup.
const CHECK_CONCURRENCY = 3;
const PROBE_TIMEOUT_MS = 12000;
App.savedSearches.NEW_CAP = NEW_CAP;
const now = () => Date.now();
// ---- store ----------------------------------------------------------
const clean = function(entry) {
if (!entry || typeof entry !== 'object') return null;
if (!entry.id || !entry.server || !entry.channelId) return null;
return {
id: String(entry.id),
name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Saved search',
server: String(entry.server),
channelId: String(entry.channelId),
channelName: typeof entry.channelName === 'string' ? entry.channelName : '',
query: typeof entry.query === 'string' ? entry.query : '',
options: entry.options && typeof entry.options === 'object' ? entry.options : {},
optionLabels: Array.isArray(entry.optionLabels) ? entry.optionLabels.map(String) : [],
createdAt: Number(entry.createdAt) || 0,
seenIds: Array.isArray(entry.seenIds) ? entry.seenIds.map(String).slice(0, SEEN_KEEP) : [],
lastViewedAt: Number(entry.lastViewedAt) || 0,
lastCheckedAt: Number(entry.lastCheckedAt) || 0,
newCount: Math.max(0, Number(entry.newCount) || 0),
unreachable: !!entry.unreachable
};
};
App.savedSearches.getAll = function() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY));
if (!Array.isArray(parsed)) return [];
return parsed.map(clean).filter(Boolean);
} catch (err) {
return [];
}
};
const setAll = function(entries) {
try {
localStorage.setItem(KEY, JSON.stringify(entries));
} catch (err) { /* a full store is not worth failing the click over */ }
return entries;
};
App.savedSearches.get = function(id) {
return App.savedSearches.getAll().find((entry) => entry.id === id) || null;
};
// Every write goes through here: the list is read again first, so a check
// that lands while the reader is renaming or deleting can't write back a
// copy of the list from before they did it.
const patch = function(id, changes) {
const entries = App.savedSearches.getAll();
const found = entries.find((entry) => entry.id === id);
if (!found) return null;
Object.assign(found, changes);
setAll(entries);
return found;
};
// ---- what is on screen right now -------------------------------------
const optionLabelsOf = function(options) {
const labels = [];
Object.values(options || {}).forEach((value) => {
if (Array.isArray(value)) {
// A multi-select that is fully on says nothing worth printing;
// the interesting case is a narrowed one.
if (value.length && value.length <= 3) {
value.forEach((opt) => labels.push(opt.title || opt.id));
} else if (value.length) {
labels.push(`${value.length} selected`);
}
} else if (value && (value.title || value.id)) {
labels.push(value.title || value.id);
}
});
return labels;
};
// The search the app is showing, in the shape a saved one is stored in.
App.savedSearches.current = function() {
const session = App.storage.getSession();
if (!session || !session.channel) return null;
const box = document.getElementById('search-input');
return {
server: session.server,
channelId: session.channel.id,
channelName: session.channel.name || session.channel.id,
query: (box ? box.value : '').trim(),
options: App.session.serializeOptions(session.options),
optionLabels: optionLabelsOf(session.options)
};
};
// Two searches are the same search when they would send the same request.
App.savedSearches.signature = function(desc) {
if (!desc) return '';
const options = desc.options || {};
const parts = Object.keys(options).sort().map((key) => {
const value = options[key];
const flat = Array.isArray(value) ? value.slice().sort().join(',') : String(value);
return `${key}=${flat}`;
});
return [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()]
.concat(parts).join('||');
};
// The saved search the app is currently showing, if any -- what makes the
// bookmark in the search bar light up.
App.savedSearches.findMatching = function() {
const desc = App.savedSearches.current();
if (!desc) return null;
const wanted = App.savedSearches.signature(desc);
return App.savedSearches.getAll()
.find((entry) => App.savedSearches.signature(entry) === wanted) || null;
};
// What to call a search nobody has named: the words searched for, or the
// channel when there were none.
const autoName = function(desc) {
if (desc.query) return desc.query;
const channel = desc.channelName || desc.channelId;
const narrowed = desc.optionLabels.slice(0, 2).join(' · ');
return narrowed ? `${channel} · ${narrowed}` : channel;
};
// The ids of what the grid is showing, newest first. Saving straight after
// a load therefore starts at "nothing new", which is the truth: the reader
// is looking at it.
const visibleIds = function() {
return (App.state.loadedVideos || [])
.slice(0, SEEN_KEEP)
.map((video) => String(video && (video.id || video.url) || ''))
.filter(Boolean);
};
App.savedSearches.saveCurrent = function() {
const desc = App.savedSearches.current();
if (!desc) return null;
const already = App.savedSearches.findMatching();
if (already) return already;
const entry = clean(Object.assign({}, desc, {
id: `ss_${now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
name: autoName(desc),
createdAt: now(),
seenIds: visibleIds(),
lastViewedAt: now(),
lastCheckedAt: now(),
newCount: 0
}));
const entries = App.savedSearches.getAll();
entries.unshift(entry);
setAll(entries);
changed();
return entry;
};
App.savedSearches.remove = function(id) {
setAll(App.savedSearches.getAll().filter((entry) => entry.id !== id));
changed();
};
App.savedSearches.rename = function(id, name) {
const next = (name || '').trim();
if (!next) return null;
const entry = patch(id, { name: next.slice(0, 80) });
changed();
return entry;
};
// Opened, so whatever it shows now counts as seen and the badge goes out.
App.savedSearches.markViewed = function(id) {
const seen = visibleIds();
const entry = patch(id, Object.assign({
lastViewedAt: now(),
lastCheckedAt: now(),
newCount: 0,
unreachable: false
}, seen.length ? { seenIds: seen } : {}));
changed();
return entry;
};
// Being on a saved search is viewing it, however the reader got there --
// by tapping it here, or by setting the same channel and filters by hand.
App.savedSearches.syncViewed = function() {
const match = App.savedSearches.findMatching();
if (match) App.savedSearches.markViewed(match.id);
return match;
};
App.savedSearches.totalNew = function() {
return App.savedSearches.getAll()
.reduce((sum, entry) => sum + (entry.newCount || 0), 0);
};
// What a count looks like on a badge: nothing, a number, or "9+".
App.savedSearches.badgeLabel = function(count) {
if (!count) return '';
return count > NEW_CAP ? `${NEW_CAP}+` : String(count);
};
const changed = function() {
if (App.ui && App.ui.savedSearches && typeof App.ui.savedSearches.sync === 'function') {
App.ui.savedSearches.sync();
}
};
// ---- the daily check -------------------------------------------------
// The ids on the first page of a saved search, as the server orders them.
// Not the grid's request: the grid's paging state belongs to what the
// reader is looking at, and a check must not disturb it.
const probeIds = async function(entry, signal) {
const serverEntry = App.storage.getServerEntries()
.find((candidate) => candidate.url === entry.server);
const serverData = serverEntry ? serverEntry.data : null;
const channel = serverData ? App.session.resolveChannelById(serverData, entry.channelId) : null;
if (!channel) throw new Error('channel is no longer listed');
const ask = async function(channelId, perPage, extra) {
const body = Object.assign({
channel: channelId,
query: entry.query || '',
page: 1,
perPage: perPage,
server: entry.server
}, extra || {});
const response = await fetch('/api/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: signal
});
if (!response.ok) throw new Error(`listing request failed: ${response.status}`);
const data = await response.json();
const items = data && Array.isArray(data.items) ? data.items : [];
return items.map((item) => String(item && (item.id || item.url) || '')).filter(Boolean);
};
if (channel.isGroup) {
const selected = entry.options ? entry.options[App.session.GROUP_CHANNELS_OPTION_ID] : null;
const enabled = (Array.isArray(selected) && selected.length ? selected : channel.channelIds)
.filter((id) => channel.channelIds.includes(id));
if (!enabled.length) return [];
const perChannel = await Promise.all(enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE)));
// Interleaved, so "the newest" means the same thing it does in the
// grid: one from each channel in turn.
const merged = [];
const longest = perChannel.reduce((max, ids) => Math.max(max, ids.length), 0);
for (let i = 0; i < longest; i++) {
perChannel.forEach((ids) => { if (ids[i]) merged.push(ids[i]); });
}
return merged;
}
const extra = {};
Object.entries(entry.options || {}).forEach(([key, value]) => {
extra[key] = Array.isArray(value) ? value.join(', ') : value;
});
return ask(entry.channelId, PROBE_PER_PAGE, extra);
};
// How many of the videos the source lists now are ones the reader has not
// been shown -- counted only as far down the page as the mark reaches.
//
// `perPage` is a request, not a promise: a source whose own pages are 30
// long answers a page of 12 with 30. The reader was shown as much of that
// as the grid asked for, so an id further down than the mark goes has not
// been seen and never was new either -- it was on the same page the whole
// time. Counting those would put a number on the badge that no amount of
// looking takes off again.
const countNew = function(ids, seen) {
return ids.slice(0, seen.size).filter((id) => !seen.has(id)).length;
};
const askSource = async function(entry) {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
return await probeIds(entry, controller.signal);
} finally {
window.clearTimeout(timer);
}
};
const checkOne = async function(entry) {
try {
const ids = await askSource(entry);
const seen = new Set(entry.seenIds || []);
// A search nobody has ever opened has nothing to compare against,
// so the whole first page is its mark rather than its news.
if (!seen.size) {
patch(entry.id, { seenIds: ids.slice(0, SEEN_KEEP), lastCheckedAt: now(), newCount: 0, unreachable: false });
return;
}
patch(entry.id, { lastCheckedAt: now(), newCount: countNew(ids, seen), unreachable: false });
} catch (err) {
// A source that is down must not look like a search with nothing
// new in it, and must not reset the clock either: it is tried
// again on the next look.
patch(entry.id, { unreachable: true });
}
};
let running = null;
// Checks every saved search that is due (or all of them, with `force`).
// Resolves when they have all been written back; safe to call at any time,
// and calling it while a run is in flight joins that run.
App.savedSearches.check = function(opts) {
const force = !!(opts && opts.force);
if (running) return running;
const due = App.savedSearches.getAll().filter((entry) =>
force || !entry.lastCheckedAt || (now() - entry.lastCheckedAt) >= DAY_MS);
if (!due.length) return Promise.resolve(0);
const queue = due.slice();
const worker = async function() {
while (queue.length) {
await checkOne(queue.shift());
changed();
}
};
running = Promise.all(
Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
).then(() => {
running = null;
changed();
return due.length;
}).catch(() => {
running = null;
changed();
return 0;
});
return running;
};
App.savedSearches.isChecking = function() { return !!running; };
let marking = null;
// The whole list, read. For a reader who sees the badge, decides none of
// it is for them, and wants the slate clean rather than nine rows to open
// one after another.
//
// The mark is moved on to what each source lists now rather than the count
// merely being zeroed: a count that is only zeroed comes back on the next
// check, because the videos it counted are still ones nobody was shown.
// That is one listing request per saved search, the same one the daily
// check makes.
App.savedSearches.markAllRead = function() {
if (marking) return marking;
const entries = App.savedSearches.getAll();
if (!entries.length) return Promise.resolve(0);
const queue = entries.slice();
const worker = async function() {
while (queue.length) {
const entry = queue.shift();
try {
const ids = await askSource(entry);
patch(entry.id, {
seenIds: ids.slice(0, SEEN_KEEP),
lastViewedAt: now(),
lastCheckedAt: now(),
newCount: 0,
unreachable: false
});
} catch (err) {
// The source did not answer, so the mark cannot be moved
// forward. The count still goes out -- the reader said they
// are done with it -- and the row says it could not be
// reached, so the number coming back later is explained.
patch(entry.id, { newCount: 0, lastViewedAt: now(), unreachable: true });
}
changed();
}
};
marking = Promise.all(
Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
).then(() => {
marking = null;
changed();
return entries.length;
}).catch(() => {
marking = null;
changed();
return 0;
});
changed();
return marking;
};
App.savedSearches.isMarkingAllRead = function() { return !!marking; };
// ---- opening one -----------------------------------------------------
// Puts the whole saved search back: source, channel, filters, query. The
// grid is reloaded here rather than left to the caller, because "applied"
// means the reader is looking at it -- which is also when it stops being
// new (see markViewed).
App.savedSearches.apply = async function(id) {
const entry = App.savedSearches.get(id);
if (!entry) return false;
const serverEntry = App.storage.getServerEntries()
.find((candidate) => candidate.url === entry.server);
if (!serverEntry || !serverEntry.data) {
App.ui.showError(`${entry.name}: the source ${entry.server} isn't set up on this device any more.`);
return false;
}
const channel = App.session.resolveChannelById(serverEntry.data, entry.channelId);
if (!channel) {
App.ui.showError(`${entry.name}: ${entry.server} no longer lists that channel.`);
patch(entry.id, { unreachable: true });
changed();
return false;
}
const session = {
server: entry.server,
channel: channel,
options: App.session.hydrateOptions(channel, entry.options)
};
App.storage.setSession(session);
App.session.savePreference(session);
App.videos.setSearchBox(entry.query || '');
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.close({ silent: true });
}
App.ui.renderMenu();
App.videos.resetGrid();
await App.videos.loadVideos();
App.savedSearches.markViewed(entry.id);
return true;
};
// ---- lifecycle -------------------------------------------------------
let started = false;
App.savedSearches.start = function() {
if (started) return;
started = true;
changed();
App.savedSearches.check();
// A tab left open for days still gets round to it.
window.setInterval(() => App.savedSearches.check(), HEARTBEAT_MS);
// And a phone that had the app in the background for a day checks as
// soon as it is looked at again, rather than at the next reload.
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') App.savedSearches.check();
});
};
})();

View File

@@ -24,7 +24,12 @@ App.constants = {
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
FAVORITES_SORT_KEY: 'favoritesSort',
PREFERRED_QUALITY_KEY: 'preferredQuality',
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
SEARCH_HISTORY_KEY: 'searchHistory',
SAVED_SEARCHES_KEY: 'savedSearches',
// Thumbnails captured out of a video whose listed picture is gone:
// the index, plus one entry per picture under `<key>:<hash>`.
POSTER_CACHE_KEY: 'posterCache'
};
// Lazily injects hls.js the first time a stream actually needs it. Sessions

View File

@@ -48,6 +48,73 @@ App.session = App.session || {};
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
};
// ---------------------------------------------------------------------
// Searches
//
// What was searched for before, newest first. The grid searches as you
// type, so the box never sees a "submit" to record: every pause in the
// typing is a search of its own and storing each one would fill the list
// with the way to a query rather than the query. So a new entry swallows
// the ones it grew out of -- typing "hot", "hote", "hotel" leaves "hotel"
// -- and going back to something shorter than what is already stored adds
// nothing, since that is a step back through the same typing.
// ---------------------------------------------------------------------
const SEARCH_HISTORY_MAX = 50;
App.storage.getSearches = function() {
try {
const stored = JSON.parse(localStorage.getItem(App.constants.SEARCH_HISTORY_KEY));
return Array.isArray(stored) ? stored.filter((q) => typeof q === 'string' && q.trim()) : [];
} catch (err) {
return [];
}
};
App.storage.rememberSearch = function(query) {
const next = (query || '').trim();
if (!next) return App.storage.getSearches();
const lower = next.toLowerCase();
const stored = App.storage.getSearches();
// Already on the way to something longer: nothing new to record.
if (stored.some((q) => q.toLowerCase() !== lower && q.toLowerCase().startsWith(lower))) {
return stored;
}
const kept = stored.filter((q) => !lower.startsWith(q.toLowerCase()));
kept.unshift(next);
const capped = kept.slice(0, SEARCH_HISTORY_MAX);
try {
localStorage.setItem(App.constants.SEARCH_HISTORY_KEY, JSON.stringify(capped));
} catch (err) { /* a full or blocked store is not worth failing a search over */ }
return capped;
};
App.storage.forgetSearches = function() {
localStorage.removeItem(App.constants.SEARCH_HISTORY_KEY);
};
// The past searches worth offering for what is being typed, best first.
// Best means where the typing lands in them -- at the front, at the start
// of a word, anywhere at all -- and then which was searched for most
// recently. What is already typed in full is not a suggestion.
App.storage.suggestSearches = function(query, limit) {
const want = Math.max(1, limit || 5);
const typed = (query || '').trim().toLowerCase();
const scored = [];
App.storage.getSearches().forEach((entry, age) => {
const candidate = entry.toLowerCase();
if (candidate === typed) return;
let rank = 0;
if (typed) {
const at = candidate.indexOf(typed);
if (at < 0) return;
rank = at === 0 ? 3 : (candidate[at - 1] === ' ' ? 2 : 1);
}
scored.push({ entry: entry, rank: rank, age: age });
});
scored.sort((a, b) => (b.rank - a.rank) || (a.age - b.age));
return scored.slice(0, want).map((s) => s.entry);
};
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
App.storage.getDensity = function() {
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';

File diff suppressed because it is too large Load Diff

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

BIN
tests/fixtures/clip.mp4 vendored Normal file

Binary file not shown.

BIN
tests/fixtures/dark.mp4 vendored Normal file

Binary file not shown.

BIN
tests/fixtures/wide.mp4 vendored Normal file

Binary file not shown.

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

248
tests/smoke_feed_swipe.py Normal file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""A swipe counts even when the viewport just moved.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_feed_swipe.py
Both the status and the listing are fixtures, so the reel is long enough to
swipe through and the test never waits on a real source.
A viewport change opens a realign window in which the feed holds the reader's
video in place, because the scroll-snap container re-anchors on positions
measured against the old height and must not be allowed to flip the video (see
smoke_rotate). The window used to revert *every* scroll it saw, and on a phone
that is the worst possible moment to do it: the chrome sliding out of the way
is caused by the swipe, so the window was open exactly when the reader was
swiping. The reel moved under the finger -- far enough to show the next video
coming in at the bottom -- and was then pulled back to where it started.
So the two halves have to be told apart, and the test asks for both: a scroll
with real input behind it is the reader's and is kept, a bare re-anchor is not
and is still undone.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
PORTRAIT = {"width": 430, "height": 930}
# Chrome sliding away: height only, so this is not a turn -- it is the resize
# that used to eat the swipe that caused it.
SHORTER = {"width": 430, "height": 860}
# Near enough to square that a little height crosses it. Reading the `w > h`
# flip on its own called that a rotation, pinned the slides and turned
# snapping off for about a second.
SQUARE = {"width": 800, "height": 810}
SQUARE_SHORT = {"width": 800, "height": 790}
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [],
}],
}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
WHERE = """() => {
const scroller = document.getElementById('feed-scroll');
const root = document.getElementById('feed-view');
const active = document.querySelector('.feed-slide.is-active');
const h = scroller.clientHeight;
return {
index: App.state.feedActiveIndex,
videoId: App.state.feedActiveVideoId,
scrollTop: Math.round(scroller.scrollTop),
expected: App.state.feedActiveIndex * h,
height: h,
activeTop: active ? Math.round(active.getBoundingClientRect().top) : None_,
turning: root.classList.contains('is-turning'),
snap: getComputedStyle(scroller).scrollSnapType,
offHeight: Array.from(document.querySelectorAll('.feed-slide'))
.map((s) => s.getBoundingClientRect().height)
.filter((sh) => Math.abs(sh - h) > 1),
};
}""".replace("None_", "null")
GO = """(step) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = step * scroller.clientHeight;
return App.state.feedActiveIndex;
}"""
# The container's own re-anchor: a scroll position and nothing behind it.
RESNAP = """(top) => {
document.getElementById('feed-scroll').scrollTop = top;
}"""
# A swipe, and the chrome reporting itself before the feed has caught up.
# setActive runs from onScroll's next frame, so doing both in one block --
# no frame boundary in between -- leaves feedActiveVideoId still naming the
# video being left, which is the state a phone resize routinely lands in.
# onResize then re-anchors synchronously, with no scroll event for the
# input guard in onScroll to see.
SWIPE_THEN_RESIZE = """() => {
const s = document.getElementById('feed-scroll');
const before = App.state.feedActiveIndex;
s.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }));
s.scrollTop = s.scrollTop + s.clientHeight;
const moved = s.scrollTop;
window.dispatchEvent(new Event('resize'));
return { before: before, moved: moved, after: s.scrollTop, h: s.clientHeight };
}"""
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 items():
return [{"id": f"vid-{i}", "title": f"clip {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
def swipe_to(page, step):
for n in range(1, step + 1):
page.evaluate(GO, n)
page.wait_for_timeout(250)
page.wait_for_timeout(400)
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",
])
page = browser.new_page(viewport=PORTRAIT)
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(800)
print("\nseven videos into the reel")
swipe_to(page, 7)
start = page.evaluate(WHERE)
c.ok("that is where the reel is", start["index"] == 7, str(start))
print("\na swipe while the chrome is still sliding away")
page.mouse.move(215, 500)
page.set_viewport_size(SHORTER)
page.wait_for_timeout(50) # well inside the realign window
page.mouse.wheel(0, SHORTER["height"])
page.wait_for_timeout(2200)
swiped = page.evaluate(WHERE)
c.ok("the swipe moved the reel on",
swiped["index"] == start["index"] + 1,
f"step {start['index']} -> {swiped['index']}")
c.ok("onto the next video",
swiped["videoId"] != start["videoId"],
f"{start['videoId']} -> {swiped['videoId']}")
c.ok("and it is sitting on that step, not between two",
abs(swiped["scrollTop"] - swiped["expected"]) <= 1, str(swiped))
c.ok("so no second video shows at the bottom of the screen",
swiped["activeTop"] == 0, str(swiped["activeTop"]))
print("\na bare re-anchor in the same window is still undone")
page.wait_for_timeout(900) # let the input grace lapse
held = page.evaluate(WHERE)
page.set_viewport_size(PORTRAIT)
page.wait_for_timeout(50)
# A position measured against the height from before the resize, which
# is what the container itself hands back mid-rotation.
page.evaluate(RESNAP, held["index"] * held["height"])
page.wait_for_timeout(1800)
resnapped = page.evaluate(WHERE)
c.ok("the same video is still playing",
resnapped["videoId"] == held["videoId"],
f"{held['videoId']} -> {resnapped['videoId']}")
c.ok("on the same step",
resnapped["index"] == held["index"],
f"{held['index']} -> {resnapped['index']}")
c.ok("re-anchored onto its own snap point",
abs(resnapped["scrollTop"] - resnapped["expected"]) <= 1, str(resnapped))
print("\nthe chrome reporting itself before the feed has caught up")
page.wait_for_timeout(900)
pre = page.evaluate(WHERE)
got = page.evaluate(SWIPE_THEN_RESIZE)
page.wait_for_timeout(2200)
kept = page.evaluate(WHERE)
print(f" (swiped to {got['moved']}px; the resize left it at {got['after']}px)")
c.ok("the resize did not haul the reel back to the video being left",
got["after"] >= got["moved"] - 1,
f"{got['moved']} -> {got['after']}")
c.ok("the swipe still landed on the next step",
kept["index"] == pre["index"] + 1,
f"step {pre['index']} -> {kept['index']}")
c.ok("and on that step's snap point",
abs(kept["scrollTop"] - kept["expected"]) <= 1, str(kept))
print("\na little height on a near-square viewport is not a rotation")
page.set_viewport_size(SQUARE)
page.wait_for_timeout(1800)
page.set_viewport_size(SQUARE_SHORT)
page.wait_for_timeout(120) # mid-window, when the pin would be on
pinned = page.evaluate(WHERE)
c.ok("the slides are not pinned for it", not pinned["turning"], str(pinned["turning"]))
c.ok("so snapping stays on", pinned["snap"] != "none", str(pinned["snap"]))
page.wait_for_timeout(1800)
settled = page.evaluate(WHERE)
c.ok("every slide is exactly a screenful",
settled["offHeight"] == [], str(settled["offHeight"]))
c.ok("and the reel rests on its snap point",
abs(settled["scrollTop"] - settled["expected"]) <= 1, str(settled))
c.ok("still on the same video",
settled["videoId"] == kept["videoId"],
f"{kept['videoId']} -> {settled['videoId']}")
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
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())

124
tests/smoke_hud.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""The reels HUD: up when you're using it, down when you're swiping past it.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_hud.py
One panel and one swipe, deliberately: what's under test is when the controls
come and go, not anything about the layout.
The presses here are dispatched rather than clicked, because moving a mouse is
itself a reason to wake the HUD and would hide what is being asked.
"""
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');
}"""
IDLE = """() => document.getElementById('feed-view').classList.contains('feed-hud-idle')"""
PRESS = """(selector) => {
const el = document.querySelector('.feed-slide.is-active ' + selector);
if (!el) return 'missing';
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
return document.getElementById('feed-view').classList.contains('feed-hud-idle');
}"""
SWIPE = """() => {
const s = document.getElementById('feed-scroll');
s.scrollTop += s.clientHeight;
}"""
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",
"--autoplay-policy=no-user-gesture-required",
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 430, "height": 930})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
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 failed.
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(2500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(400)
print("\nopening the feed")
c.ok("the controls are up to start with", page.evaluate(IDLE) is False)
print("\nleaving it alone")
page.wait_for_timeout(1600)
c.ok("they fade on their own", page.evaluate(IDLE) is True)
print("\nswiping to the next reel")
page.evaluate(SWIPE)
page.wait_for_timeout(300)
c.ok("the swipe does not put them back up mid-flight", page.evaluate(IDLE) is True)
page.wait_for_timeout(2500)
c.ok("nor once it has landed", page.evaluate(IDLE) is True)
c.ok("and the reel it landed on is playing",
page.evaluate("""() => {
const v = document.querySelector('.feed-slide.is-active .feed-video');
return !!v && !v.paused;
}"""))
print("\nwhat does bring them back")
c.ok("pressing the video -- which is how a swipe begins -- does not",
page.evaluate(PRESS, ".feed-video") is True)
c.ok("pressing a control does", page.evaluate(PRESS, ".feed-pane-mute") is False)
page.wait_for_timeout(1600)
c.ok("after which they fade again", page.evaluate(IDLE) is True)
page.mouse.move(200, 400)
page.mouse.move(210, 420)
page.wait_for_timeout(200)
c.ok("moving the mouse does too", page.evaluate(IDLE) is False)
print("\na swipe started while they are up")
c.ok("puts them away", (page.evaluate(SWIPE), page.wait_for_timeout(500),
page.evaluate(IDLE))[2] is True)
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())

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

332
tests/smoke_parallel.py Normal file
View File

@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""A split step loads its videos together and ahead of time, not one by one.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_parallel.py
The listing and the media are fixtures, so what is measured is the feed and not
somebody's CDN: every clip takes the same MEDIA_DELAY_MS to arrive, which is
far longer than a step that was preloaded needs to start.
Four panels, which is the case this is for. With one step preloaded between
them, a panel did its loading at the moment it was swiped to -- on a real CDN
that is four starts in a row, and here it is four late ones. The second swipe
is where it shows: the first lands on the step that was preloaded either way,
and it comes quickly enough that the step after it cannot have loaded since.
"""
import base64
import http.server
import json
import socketserver
import sys
import threading
import time
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
PER_PAGE = 12
PAGES = 8
MEDIA_DELAY_MS = 2000 # what a clip costs if it isn't already loading
READY_MS = 400 # a start this quick means the clip was already there
# One second of black, 64x64, h264 -- a real file, because the panels have to
# decode a frame from it. Same blob as smoke_open.py.
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():
"""Serves the fixture clip, after a delay, however many at a time."""
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_GET(self):
time.sleep(MEDIA_DELAY_MS / 1000)
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)
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([]));
}"""
# Which videos were asked to resolve their formats. Prefilled formats make the
# answer immediate, but the asking is what says when the feed goes looking.
SPY = """() => {
window.__resolved = [];
const real = App.videos.ensureFormats;
App.videos.ensureFormats = function(video) {
if (video && video.id) window.__resolved.push(String(video.id));
return real.apply(this, arguments);
};
}"""
PANES = """() => Array.from(document.querySelectorAll('.feed-slide')).map((slide) => ({
step: Number(slide.dataset.step),
loaded: Array.from(slide.querySelectorAll('.feed-pane'))
.map((pane) => pane.classList.contains('is-loaded')),
ids: Array.from(slide.querySelectorAll('.feed-pane')).map((pane) => pane.dataset.videoId),
}))"""
WATCH = """() => {
window.__t0 = performance.now();
window.__started = {};
document.addEventListener('playing', (e) => {
const pane = e.target.closest && e.target.closest('.feed-pane');
if (!pane) return;
const id = pane.dataset.videoId;
if (!(id in window.__started)) window.__started[id] = Math.round(performance.now() - window.__t0);
}, true);
}"""
SWIPE = """() => { const s = document.getElementById('feed-scroll'); s.scrollTop += s.clientHeight; }"""
CLICK = """(sel) => {
const el = document.querySelector(sel);
if (!el) return 'missing';
el.click(); // dispatched, so the pointer never wakes the HUD
return 'ok';
}"""
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 make_items(page_no, media):
"""One page of the listing, formats already resolved so nothing but the
media itself stands between a panel and its first frame."""
start = (page_no - 1) * PER_PAGE
items = []
for i in range(PER_PAGE):
n = start + i
url = f"{media}/v{n}.mp4"
items.append({
"id": f"fix-{n}",
"title": f"Fixture {n}",
"url": "https://example.test/watch/%d" % n,
"channel": "test",
"duration": 1,
"thumb": "",
"tags": [],
"meta": {
"url": url,
"http_headers": {},
"isLive": False,
"formats": [{"url": url, "ext": "mp4", "height": 240, "protocol": "https",
"vcodec": "avc1", "acodec": "none"}],
},
})
return items
def main():
c = Checks()
httpd = make_server()
media = f"http://127.0.0.1:{httpd.server_address[1]}"
# Listing requests are parked as they arrive and answered a beat later, so
# how many the feed has out at once is something the test can see.
parked = []
busiest = {"max": 0}
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=768",
])
page = browser.new_page(viewport={"width": 800, "height": 900})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
def park(route):
parked.append(route)
busiest["max"] = max(busiest["max"], len(parked))
def answer():
while parked:
route = parked.pop(0)
try:
body = json.loads(route.request.post_data or "{}")
except Exception:
body = {}
page_no = int(body.get("page", 1) or 1)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"items": make_items(page_no, media) if page_no <= PAGES else [],
"pageInfo": {"hasNextPage": page_no < PAGES},
}))
def settle(ms):
"""Let the page run, answering listing requests as they pile up."""
for _ in range(max(1, ms // 100)):
page.wait_for_timeout(100)
answer()
page.route("**/api/videos", park)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
settle(1500)
page.wait_for_selector(".video-card", timeout=30000)
page.evaluate(SPY)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
settle(3000)
print("\nsplitting the step four ways")
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(1) .feed-pane-split-right")
settle(1500)
for nth in (1, 2):
page.evaluate(CLICK, f".feed-slide.is-active .feed-pane:nth-of-type({nth}) .feed-pane-split-down")
settle(1500)
settle(3000)
per = page.evaluate("() => App.feed.paneCount()")
c.ok("four panels", per == 4, str(per))
panes = page.evaluate(PANES)
active = page.evaluate("() => App.state.feedActiveIndex")
ahead = [s for s in panes if s["step"] > active]
loaded_ahead = sum(1 for s in ahead for is_loaded in s["loaded"] if is_loaded)
want = per * 3
print(f" ({loaded_ahead} videos loading ahead, over {len(ahead)} steps)")
c.ok(f"every panel keeps its next three videos ready ({want} of them)",
loaded_ahead >= want, f"loaded {loaded_ahead} of {sum(len(s['loaded']) for s in ahead)}")
resolved = set(page.evaluate("() => window.__resolved"))
missing = [i for s in ahead for i in s["ids"] if i not in resolved]
c.ok("and their formats were asked for up front, not at load time",
not missing, f"never resolved: {missing[:4]}")
def swipe_and_time(label, watch_ms):
page.evaluate(WATCH)
page.evaluate(SWIPE)
settle(watch_ms)
started = page.evaluate("() => window.__started")
ids = page.evaluate("""() => Array.from(
document.querySelectorAll('.feed-slide.is-active .feed-pane')).map(p => p.dataset.videoId)""")
times = [started[i] for i in ids if i in started]
print(f" ({label}: started at {sorted(times)} ms after the swipe)")
return ids, times
print("\nswiping to the next step")
# Measured briefly, and on purpose: the step after this one must not get
# a quiet moment to load in, or the swipe below tests nothing.
ids, times = swipe_and_time("first swipe", 800)
c.ok("it landed on the next step",
page.evaluate("() => App.state.feedActiveIndex") == active + 1)
c.ok("all four panels started, together",
len(times) == len(ids) and (max(times) - min(times)) < READY_MS,
f"{len(times)} of {len(ids)} started; at {sorted(times)}")
print("\nand straight on to the one after it")
ids, times = swipe_and_time("second swipe", 3000)
c.ok("it landed there too",
page.evaluate("() => App.state.feedActiveIndex") == active + 2)
c.ok("every panel of it is playing",
page.evaluate("""() => Array.from(document.querySelectorAll('.feed-slide.is-active .feed-video'))
.every(v => !v.paused)"""))
c.ok("all four started, together",
len(times) == len(ids) and (max(times) - min(times)) < READY_MS,
f"{len(times)} of {len(ids)} started; at {sorted(times)}")
c.ok(f"and at once, because the clips were already there (a clip takes {MEDIA_DELAY_MS}ms)",
times and max(times) < READY_MS,
f"slowest {max(times) if times else 'n/a'}ms")
print("\nfeeding the split")
c.ok("more than one page of listing is fetched at a time",
busiest["max"] > 1, f"most at once: {busiest['max']}")
now_active = page.evaluate("() => App.state.feedActiveIndex")
c.ok("and the buffer stays ahead of what a swipe eats",
page.evaluate("() => App.state.loadedVideos.length") > (now_active + 1) * per + per * 3,
str(page.evaluate("() => App.state.loadedVideos.length")))
print("\nclosing a panel")
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(2) .feed-pane-close")
settle(2000)
c.ok("what is left plays on", page.evaluate("""() => {
const v = document.querySelector('.feed-slide.is-active .feed-video');
return !!v && !v.paused;
}"""))
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())

148
tests/smoke_peek.py Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Reels peeking: the next step plays as it slides in, and rewinds if it doesn't.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_peek.py
A half-finished swipe is the thing under test, so the scroller's snapping is
switched off for the duration: a finger holds the list between two steps, and
`scrollTop` can only do the same with mandatory snapping out of the way.
"""
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');
}"""
# Every step in the window, by its index, with what its video is doing.
STEPS = """() => {
const out = {};
document.querySelectorAll('.feed-slide').forEach((slide) => {
const video = slide.querySelector('.feed-video');
out[slide.dataset.step] = {
active: slide.classList.contains('is-active'),
loaded: !!slide.querySelector('.feed-pane.is-loaded'),
paused: video ? video.paused : null,
muted: video ? video.muted : null,
t: video ? Math.round(video.currentTime * 10) / 10 : null,
};
});
return { steps: out, activeIndex: App.state.feedActiveIndex };
}"""
DRAG = """(fraction) => {
const scroller = document.getElementById('feed-scroll');
// A finger can hold the list between two steps; scrollTop can only do the
// same with mandatory snapping out of the way.
scroller.style.scrollSnapType = 'none';
const height = scroller.clientHeight;
scroller.scrollTop = Math.round(height * fraction);
return scroller.scrollTop;
}"""
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(SEED, [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",
])
page = browser.new_page(viewport={"width": 430, "height": 930})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
open_reels(page)
start = page.evaluate(STEPS)
c.ok("the first step is playing", start["steps"]["0"]["paused"] is False, str(start))
print("\na swipe barely begun")
page.evaluate(DRAG, 0.18)
page.wait_for_timeout(1800)
peek = page.evaluate(STEPS)
nxt = peek["steps"].get("1")
c.ok("the step sliding in is already playing", nxt and nxt["paused"] is False, str(peek))
c.ok("silently, so it doesn't talk over the one being left",
nxt and nxt["muted"] is True, str(nxt))
c.ok("and the one being left plays on",
peek["steps"]["0"]["paused"] is False, str(peek["steps"]["0"]))
c.ok("without the swipe counting as done", peek["activeIndex"] == 0,
str(peek["activeIndex"]))
print("\nthinking better of it")
page.evaluate(DRAG, 0)
page.wait_for_timeout(1200)
page.evaluate("() => App.feed._settlePeek()")
page.wait_for_timeout(400)
back = page.evaluate(STEPS)
nxt = back["steps"].get("1")
c.ok("the step that slid back in is stopped", nxt and nxt["paused"] is True, str(nxt))
c.ok("and wound back to the start", nxt and nxt["t"] == 0, str(nxt))
c.ok("the one being watched never stopped",
back["steps"]["0"]["paused"] is False, str(back["steps"]["0"]))
print("\nswiping again starts it from the top")
page.evaluate(DRAG, 0.2)
page.wait_for_timeout(1500)
again = page.evaluate(STEPS)["steps"].get("1")
c.ok("it plays again", again and again["paused"] is False, str(again))
c.ok("from the beginning, not from where the first peek got to",
again and again["t"] is not None and again["t"] < 2.5, str(again))
print("\ncarrying the swipe through")
page.evaluate(DRAG, 1)
page.wait_for_timeout(2500)
page.evaluate("() => App.feed._settlePeek()")
page.wait_for_timeout(400)
done = page.evaluate(STEPS)
arrived = done["steps"].get("1")
c.ok("the step arrived at is the active one", done["activeIndex"] == 1, str(done["activeIndex"]))
c.ok("it is playing", arrived and arrived["paused"] is False, str(arrived))
c.ok("it was never wound back", arrived and arrived["t"] > 0, str(arrived))
c.ok("and it carries the feed's own sound setting",
arrived and arrived["muted"] == page.evaluate("() => App.state.feedMuted !== false"),
str(arrived))
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())

292
tests/smoke_poster_black.py Normal file
View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""A captured thumbnail has to be a picture of something.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_poster_black.py
Two ways a card ends up a black box rather than a picture, both of which the
capture used to keep for the life of the item -- the only test a frame had to
pass was being a few hundred bytes long:
* the frame at the mark is black. A video that opens on a fade, or whose
length nobody will say (so there is no quarter mark to seek to and the
opening frame is all there is), hands over exactly that.
* the element decoded nothing. Drawing one that has not presented a frame
gives an untouched canvas, which is a pure black JPEG about 1.6 KB long.
The fixture is black for its first three and a half seconds -- past the quarter
mark a capture aims for -- and flat orange after, so the picture on the card
says whether anything looked at what it was keeping.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "dark.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
CACHE_KEY = "posterCache"
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [],
}],
}
# Saved with the thumbnail it had at the time, and with nothing known about how
# long it is -- which is the state the item this test comes from is in.
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.removeItem('posterCache');
Object.keys(localStorage).forEach((k) => {
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
});
localStorage.setItem('favorites', JSON.stringify([{
key: 'vid-0',
id: 'vid-0',
url: 'https://fixture.test/v0.mp4',
title: 'kept clip 0',
thumb: 'https://pictures.fixture.test/gone/0.jpg',
channel: channel,
uploader: '',
duration: 0,
isLive: false,
favoriteDate: new Date().toISOString()
}]));
}"""
CAPTURED = """(id) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
if (!card) return false;
const img = card.querySelector('.video-thumb img');
return (img.getAttribute('src') || '').indexOf('data:image/jpeg') === 0;
}"""
# What the card is showing, measured: the brightest sample in it and the
# average, which is all it takes to tell a picture from a black box.
LIGHT = """(id) => new Promise((resolve) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
const img = card ? card.querySelector('.video-thumb img') : null;
const src = img ? (img.getAttribute('src') || '') : '';
if (!src) { resolve(null); return; }
const probe = new Image();
probe.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = probe.naturalWidth;
canvas.height = probe.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(probe, 0, 0);
const px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let sum = 0, brightest = 0;
for (let i = 0; i < px.length; i += 4) {
const value = Math.max(px[i], px[i + 1], px[i + 2]);
sum += value;
if (value > brightest) brightest = value;
}
resolve({ brightest, mean: Math.round(sum / (px.length / 4)),
bytes: src.length, w: canvas.width, h: canvas.height });
};
probe.onerror = () => resolve(null);
probe.src = src;
})"""
CACHE = """() => JSON.parse(localStorage.getItem('posterCache') || '[]')"""
FORGET = """() => {
localStorage.removeItem('posterCache');
Object.keys(localStorage).forEach((k) => {
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
});
}"""
# The item this test comes from has no duration: the extractor reports none and
# the favorite kept a zero. Where the browser can't supply one either, there is
# no quarter mark to seek to -- and the frame the element decodes on its own is
# the opening one, which is the frame this clip is black for.
NO_DURATION = """
Object.defineProperty(HTMLVideoElement.prototype, 'duration', {
configurable: true,
get: function() { return NaN; }
});
"""
# What the version before this kept: an untouched canvas, JPEG'd. Written over
# the picture in the cache, with the "this was looked at" marker taken off the
# index entry, which is exactly the state those sessions left behind.
POISON = """() => {
const canvas = document.createElement('canvas');
canvas.width = 360;
canvas.height = 203;
const black = canvas.toDataURL('image/jpeg', 0.72);
const index = JSON.parse(localStorage.getItem('posterCache') || '[]');
index.forEach((entry) => {
delete entry.v;
entry.n = black.length;
localStorage.setItem(`posterCache:${entry.k}`, black);
});
localStorage.setItem('posterCache', JSON.stringify(index));
return { black: black, entries: index.length };
}"""
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 items():
return [{"id": "vid-0", "title": "clip 0",
"url": "https://fixture.test/v0.mp4",
"thumb": "https://pictures.fixture.test/gone/0.jpg",
"channel": CHANNEL, "duration": 0, "isLive": False, "tags": []}]
def main():
c = Checks()
streamed = []
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": 1180, "height": 920})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
page.route(re.compile(r"pictures\.fixture\.test"),
lambda route: route.fulfill(status=404, body=""))
page.route(re.compile(r"/api/image"),
lambda route: route.fulfill(status=404, body=""))
page.route(re.compile(r"/api/poster"), lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps({"thumb": ""})))
# Byte ranges and all: a response a browser cannot seek in is one it
# will not take a frame out of the middle of.
def stream(route):
streamed.append(route.request.url)
body = open(CLIP, "rb").read()
span = re.match(r"bytes=(\d+)-(\d*)", route.request.headers.get("range") or "")
if span:
start = int(span.group(1))
end = int(span.group(2)) if span.group(2) else len(body) - 1
chunk = body[start:end + 1]
route.fulfill(status=206, body=chunk, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Range": f"bytes {start}-{end}/{len(body)}",
"Content-Length": str(len(chunk)),
})
return
route.fulfill(status=200, body=body, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Length": str(len(body)),
})
page.route(re.compile(r"/api/stream"), stream)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
print("\nthe quarter mark of this clip is black")
page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000)
shot = page.evaluate(LIGHT, "vid-0")
c.ok("the card ended up with a captured picture", shot is not None, str(shot))
c.ok("and it is not a black box",
shot and shot["brightest"] > 40, str(shot))
c.ok("it is the orange the clip shows past its lead-in",
shot and shot["mean"] > 60, str(shot))
c.ok("at card width, so it is a thumbnail and not a frame of video",
shot and shot["w"] == 360, str(shot))
print("\nand the picture it kept says it was looked at")
cached = page.evaluate(CACHE)
c.ok("one picture is in the cache", len(cached) == 1, str(cached))
c.ok("marked as checked", cached and cached[0].get("v") == 1, str(cached))
print("\na black picture kept by an earlier version is replaced")
poisoned = page.evaluate(POISON)
c.ok("the cache now holds what that version would have kept",
poisoned["entries"] == 1, str(poisoned["entries"]))
streamed.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
# A flat frame weighs about what a blank one does -- both are a single
# colour -- so what says the card moved on is the picture itself.
page.wait_for_function(
"""([id, black]) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
const img = card && card.querySelector('.video-thumb img');
const src = img ? (img.getAttribute('src') || '') : '';
// The listed picture is on the card while the ladder walks
// down it, so what counts is a captured picture that is not
// the one that was there.
return src.indexOf('data:image/jpeg') === 0 && src !== black;
}""", arg=["vid-0", poisoned["black"]], timeout=60000)
healed = page.evaluate(LIGHT, "vid-0")
c.ok("the card shows a picture again", healed is not None, str(healed))
c.ok("not the black one it was given", healed and healed["brightest"] > 40, str(healed))
c.ok("taken from the video, which was fetched for it",
any("/api/stream" in url for url in streamed), str(streamed[:2]))
kept = page.evaluate(CACHE)
c.ok("and the replacement is what is kept now",
len(kept) == 1 and kept[0].get("v") == 1, str(kept))
print("\nand it is not measured a second time")
streamed.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(2000)
again = page.evaluate(LIGHT, "vid-0")
c.ok("the kept picture is on the card straight away",
again and again["brightest"] > 40, str(again))
c.ok("without going back to the video", streamed == [], str(streamed))
print("\nand a clip nobody will say the length of is not left on frame one")
page.evaluate(FORGET)
page.add_init_script(NO_DURATION)
streamed.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
c.ok("the browser gives no length to go by",
page.evaluate("() => String(document.createElement('video').duration)") == 'NaN')
page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000)
blind = page.evaluate(LIGHT, "vid-0")
c.ok("a frame came out of the video anyway", blind is not None, str(blind))
c.ok("and it is not the black one it opens on",
blind and blind["brightest"] > 40, str(blind))
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())

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""A favorite whose thumbnail has expired gets a frame out of the video instead.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_poster_capture.py
Everything the client would reach for is a fixture, and every one of them is
dead on purpose: the listed picture 404s on both routes, the image proxy 404s,
and the source page has no replacement to offer. That is the state a favorite
saved months ago is actually in -- the signature on its CDN thumbnail expired --
and what the test asks is that the card still ends up with a picture of the
video, scaled down, kept, and not fetched again.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
# 1280x720, eight seconds. Wide on purpose: the capture has to scale down to
# card size, and a fixture no wider than the cache's own width would never show
# whether it does.
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "wide.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
# What App.posters scales a frame down to, and what it keeps the cache under.
POSTER_WIDTH = 360
CACHE_KEY = "posterCache"
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [],
}],
}
# The favorite is the first item of the listing, saved with the thumbnail it
# had at the time -- the one that no longer loads.
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.removeItem('posterCache');
Object.keys(localStorage).forEach((k) => {
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
});
localStorage.setItem('favorites', JSON.stringify([{
key: 'vid-0',
id: 'vid-0',
url: 'https://fixture.test/v0.mp4',
title: 'kept clip 0',
thumb: 'https://pictures.fixture.test/gone/0.jpg',
channel: channel,
uploader: '',
duration: 8,
isLive: false,
favoriteDate: new Date().toISOString()
}]));
}"""
# The picture on a card, and whether it is one: a broken <img> has no
# intrinsic size.
SHOT = """(id) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
if (!card) return null;
const img = card.querySelector('.video-thumb img');
return {
src: (img.getAttribute('src') || '').slice(0, 24),
length: (img.getAttribute('src') || '').length,
width: img.naturalWidth,
height: img.naturalHeight
};
}"""
CACHE = """() => {
const index = JSON.parse(localStorage.getItem('posterCache') || '[]');
return index.map((entry) => ({
bytes: entry.n,
stored: (localStorage.getItem(`posterCache:${entry.k}`) || '').slice(0, 24),
length: (localStorage.getItem(`posterCache:${entry.k}`) || '').length
}));
}"""
PIXEL = """([id, x, y]) => new Promise((resolve) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
const img = card ? card.querySelector('.video-thumb img') : null;
if (!img) { resolve(null); return; }
const probe = new Image();
probe.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = probe.width;
canvas.height = probe.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(probe, 0, 0);
const px = ctx.getImageData(x, y, 1, 1).data;
resolve({ r: px[0], g: px[1], b: px[2] });
};
probe.onerror = () => resolve(null);
probe.src = img.getAttribute('src');
})"""
CAPTURED = """(id) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
if (!card) return false;
const img = card.querySelector('.video-thumb img');
return (img.getAttribute('src') || '').indexOf('data:image/jpeg') === 0;
}"""
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 items():
# The Referer is what gives an item a source page, which is the repair that
# comes before a capture. A favorite has none -- normalize() keeps no
# headers, since theirs expire -- so a saved item goes straight to the
# frame, while the same item in the listing walks the whole ladder.
return [{"id": f"vid-{i}", "title": f"clip {i}",
"url": f"https://fixture.test/v{i}.mp4",
"thumb": f"https://pictures.fixture.test/gone/{i}.jpg",
"http_headers": {"Referer": "https://page.fixture.test/"},
"channel": CHANNEL, "duration": 8, "isLive": False, "tags": []}
for i in range(3)]
def main():
c = Checks()
streamed = []
posters = []
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": 1180, "height": 920})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
# Every way to the listed picture is gone: the provider, and our own
# image proxy standing in front of it.
page.route(re.compile(r"pictures\.fixture\.test"),
lambda route: route.fulfill(status=404, body=""))
page.route(re.compile(r"/api/image"),
lambda route: route.fulfill(status=404, body=""))
# And the source page has no fresher one to read off.
def poster(route):
posters.append(route.request.url)
route.fulfill(status=200, content_type="application/json",
body=json.dumps({"thumb": ""}))
page.route(re.compile(r"/api/poster"), poster)
# The video itself, which is the one thing that still works. Served
# the way the real proxy serves it -- byte ranges and all -- because a
# response a browser cannot seek in is one it will not capture a frame
# a quarter of the way into.
def stream(route):
streamed.append(route.request.url)
body = open(CLIP, "rb").read()
asked = route.request.headers.get("range") or ""
span = re.match(r"bytes=(\d+)-(\d*)", asked)
if span:
start = int(span.group(1))
end = int(span.group(2)) if span.group(2) else len(body) - 1
chunk = body[start:end + 1]
route.fulfill(status=206, body=chunk, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Range": f"bytes {start}-{end}/{len(body)}",
"Content-Length": str(len(chunk)),
})
return
route.fulfill(status=200, body=body, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Length": str(len(body)),
})
page.route(re.compile(r"/api/stream"), stream)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
print("\nthe listed picture is gone, on every route there is")
c.ok("the module is there", page.evaluate("() => typeof App.posters.capture") == 'function')
c.ok("nothing is cached to begin with", page.evaluate(CACHE) == [], str(page.evaluate(CACHE)))
print("\nso the card takes a frame out of the video")
page.wait_for_function(CAPTURED, arg="vid-0", timeout=40000)
shot = page.evaluate(SHOT, "vid-0")
c.ok("the favorite's thumbnail is a captured frame",
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
c.ok("and it decoded, so it is a picture and not an empty box",
shot["height"] > 0, str(shot))
c.ok("scaled down to card width, not kept at video size",
shot["width"] == POSTER_WIDTH, str(shot["width"]))
c.ok("the aspect ratio came through",
abs(shot["height"] - round(POSTER_WIDTH * 720 / 1280)) <= 1, str(shot))
c.ok("it was asked for at the quarter mark, from the video itself",
any("/api/stream" in url for url in streamed), str(streamed[:2]))
c.ok("and the source page was asked for a fresh picture before that",
any("/api/poster" in url for url in posters), str(posters[:2]))
print("\nthe frame is the one a quarter of the way in")
# The fixture is three bands of flat colour, so the frame says when it
# was taken: red for the first second and a half, orange from there to
# two and a half -- which is where a quarter of eight seconds falls --
# and navy for the rest. A capture that drew the first frame it decoded
# instead of seeking would come out red, and one that waited for the
# middle, navy.
frame = page.evaluate(PIXEL, ["vid-0", 180, 100])
c.ok("the colour is the one the clip shows two seconds in",
frame is not None and frame["r"] > 190 and 100 < frame["g"] < 180 and frame["b"] < 110,
str(frame))
c.ok("so it is neither the opening frame nor the middle",
frame is not None and frame["g"] > 80 and not (frame["b"] > frame["r"]), str(frame))
print("\nand keeps it")
cached = page.evaluate(CACHE)
c.ok("one picture is in the cache", len(cached) == 1, str(cached))
c.ok("stored as a JPEG data URL",
cached[0]["stored"].startswith("data:image/jpeg"), str(cached[0]["stored"]))
c.ok("small enough to keep a hundred of them",
0 < cached[0]["length"] < 60000, str(cached[0]["length"]))
c.ok("and the index says how big it is",
cached[0]["bytes"] == cached[0]["length"], str(cached[0]))
print("\nthe videos nobody kept are left alone")
others = [page.evaluate(SHOT, "vid-1"), page.evaluate(SHOT, "vid-2")]
c.ok("a card that is not a favorite captures nothing",
all(not (shot and shot["src"].startswith("data:")) for shot in others), str(others))
c.ok("so exactly one video was fetched for a picture",
len(streamed) == 1, str(streamed))
print("\nand the next session does not fetch it again")
streamed.clear()
posters.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(1500)
shot = page.evaluate(SHOT, "vid-0")
c.ok("the kept picture is on the card straight away",
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
c.ok("without going back to the video", streamed == [], str(streamed))
c.ok("or to the source page", posters == [], str(posters))
c.ok("and it is still the same one picture in the cache",
len(page.evaluate(CACHE)) == 1, str(page.evaluate(CACHE)))
print("\nthe favorites bar shows it too")
bar = page.evaluate("""() => {
const img = document.querySelector('.favorite-card img');
return img ? (img.getAttribute('src') || '').slice(0, 24) : null;
}""")
c.ok("the bar card is the captured frame as well",
bar is not None and bar.startswith("data:image/jpeg"), str(bar))
print("\nand the reader can have them taken again")
streamed.clear()
page.click(".settings-toggle")
c.ok("the setting says what is being kept",
"1 picture kept" in page.inner_text("#clear-posters-status"),
page.inner_text("#clear-posters-status"))
page.click("#clear-posters-btn")
c.ok("nothing is kept any more", page.evaluate(CACHE) == [], str(page.evaluate(CACHE)))
c.ok("and it says what it threw away",
"Threw away 1 picture" in page.inner_text("#clear-posters-status"),
page.inner_text("#clear-posters-status"))
page.wait_for_function(CAPTURED, arg="vid-0", timeout=40000)
shot = page.evaluate(SHOT, "vid-0")
c.ok("the card has a frame out of the video again",
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
c.ok("which was fetched for it, this being the point of the setting",
any("/api/stream" in url for url in streamed), str(streamed[:2]))
c.ok("and kept again, as one picture",
len(page.evaluate(CACHE)) == 1, str(page.evaluate(CACHE)))
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())

141
tests/smoke_privacy.py Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""The privacy veil: the page blurs whenever this window is not the one in front.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_privacy.py
Headless Chromium keeps every tab visible and focused -- opening a second tab
and bringing it to the front changes neither, and neither does freezing the
page over CDP -- so there is no way to genuinely send this window to the back.
The transitions are therefore dispatched by hand, which tests what this change
owns -- what the page does when the browser says it has gone away -- and not
what the browser does with a backgrounded tab.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
VEILED = """() => {
const veil = document.getElementById('privacy-veil');
return !!veil && veil.classList.contains('is-on');
}"""
OPACITY = """() => getComputedStyle(document.getElementById('privacy-veil')).opacity"""
# What a tap in the middle of the window would land on.
UNDER_FINGER = """() => {
const el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
return el ? el.id || el.className || el.tagName : null;
}"""
# Neither a second tab nor CDP can put this window in the background, so the
# two halves of a tab switch are announced the way the browser would announce
# them.
HIDE = """() => {
Object.defineProperty(document, 'visibilityState',
{ configurable: true, get: () => 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
}"""
SHOW = """() => {
Object.defineProperty(document, 'visibilityState',
{ configurable: true, get: () => 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
}"""
# The highest z-index anywhere in the app's own stylesheet, veil excluded: the
# veil has to beat the player and the reels feed, not just the page behind them.
HIGHEST_OTHER = """() => {
let top = 0;
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch (err) { continue; }
for (const rule of rules || []) {
if (!rule.selectorText || rule.selectorText.includes('privacy-veil')) continue;
const z = parseInt(rule.style && rule.style.zIndex, 10);
if (!isNaN(z) && z > top) top = z;
}
}
return top;
}"""
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",
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 430, "height": 930})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.goto(BASE, wait_until="load")
page.wait_for_function("() => window.App && App.privacy && App.privacy.isVeiled", timeout=30000)
page.wait_for_timeout(300)
print("\nthe window in front")
c.ok("nothing is veiled", page.evaluate(VEILED) is False)
c.ok("and the veil is out of the way", page.evaluate(OPACITY) == "0")
c.ok("so taps reach the page", page.evaluate(UNDER_FINGER) != "privacy-veil",
str(page.evaluate(UNDER_FINGER)))
print("\nthe tab going to the back")
page.evaluate(HIDE)
c.ok("the page goes behind the blur", page.evaluate(VEILED) is True)
c.ok("fully, and at once", page.evaluate(OPACITY) == "1")
c.ok("with nothing underneath reachable", page.evaluate(UNDER_FINGER) == "privacy-veil",
str(page.evaluate(UNDER_FINGER)))
c.ok("and it sits above everything the app can show",
page.evaluate("() => parseInt(getComputedStyle(document.getElementById('privacy-veil')).zIndex, 10)")
> page.evaluate(HIGHEST_OTHER),
str(page.evaluate(HIGHEST_OTHER)))
print("\ncoming back to it")
page.evaluate(SHOW)
page.wait_for_timeout(300)
c.ok("the blur lifts", page.evaluate(VEILED) is False)
print("\nlosing focus without leaving the tab -- another window, say")
page.evaluate("() => window.dispatchEvent(new Event('blur'))")
c.ok("the page goes behind the blur too", page.evaluate(VEILED) is True)
page.evaluate("() => window.dispatchEvent(new Event('focus'))")
c.ok("and comes back when focus does", page.evaluate(VEILED) is False)
print("\na touch on the veil")
page.evaluate("() => window.dispatchEvent(new Event('blur'))")
c.ok("veiled to start with", page.evaluate(VEILED) is True)
page.evaluate("""() => document.getElementById('privacy-veil')
.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))""")
c.ok("lifts it rather than pressing what is underneath", page.evaluate(VEILED) is False)
print("\nthe tab going away altogether")
page.evaluate("() => window.dispatchEvent(new Event('pagehide'))")
c.ok("leaves it blurred behind us", page.evaluate(VEILED) is True)
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())

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

208
tests/smoke_rotate.py Normal file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Turning the phone keeps the same video on screen.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_rotate.py
Both the status and the listing are fixtures, so the reel is long enough to
skip forward in and the test never waits on a real source.
A rotation is not one event: the browser fires `resize` several times as its
chrome and the viewport settle, and the scroll-snap container re-anchors on
pixel positions measured against the old height somewhere in the middle of
that. The test replays exactly that -- a second `resize` and then a stale
scroll position -- and asks that the reel still be on the video it was on.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
# A real (tiny, navy, silent) clip: the feed drops a video whose source fails,
# and a reel that empties itself has no scroll positions to get wrong.
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
PORTRAIT = {"width": 430, "height": 930}
LANDSCAPE = {"width": 930, "height": 430}
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [],
}],
}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
WHERE = """() => {
const scroller = document.getElementById('feed-scroll');
const active = document.querySelector('.feed-slide.is-active');
const h = scroller.clientHeight;
return {
index: App.state.feedActiveIndex,
videoId: App.state.feedActiveVideoId,
scrollTop: scroller.scrollTop,
expected: App.state.feedActiveIndex * h,
height: h,
// Where the active slide actually sits: the one thing the reader sees.
activeTop: active ? Math.round(active.getBoundingClientRect().top) : null,
activeId: active ? active.dataset.step : null,
// Every rendered slide must be exactly a scroller tall, or
// `index * clientHeight` stops addressing the slide it names.
offHeight: Array.from(document.querySelectorAll('.feed-slide'))
.map((s) => s.getBoundingClientRect().height)
.filter((sh) => Math.abs(sh - h) > 1)
};
}"""
# A rotation as the browser actually delivers it: another resize while things
# are still moving, then the container's own re-anchor on a position measured
# against the height from before.
ROTATION_NOISE = """(oldHeight) => new Promise((resolve) => {
const scroller = document.getElementById('feed-scroll');
const index = App.state.feedActiveIndex;
setTimeout(() => window.dispatchEvent(new Event('resize')), 60);
setTimeout(() => {
scroller.scrollTop = index * oldHeight;
resolve(index * oldHeight);
}, 140);
})"""
GO = """(step) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = step * scroller.clientHeight;
return App.state.feedActiveIndex;
}"""
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 swipe_to(page, step):
"""One step at a time, the way a thumb gets there: only the slides around
the active one exist, so the scroll range doesn't reach step 7 in one go."""
for n in range(1, step + 1):
page.evaluate(GO, n)
page.wait_for_timeout(250)
page.wait_for_timeout(400)
def items():
return [{"id": f"vid-{i}", "title": f"clip {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
def rotate(page, c, size, label):
before = page.evaluate(WHERE)
page.set_viewport_size(size)
stale = page.evaluate(ROTATION_NOISE, before["height"])
page.wait_for_timeout(1400)
after = page.evaluate(WHERE)
print(f" (was step {before['index']} at {before['scrollTop']}px of {before['height']};"
f" the stale re-anchor asked for {stale}px)")
c.ok(f"{label}: the same video is still the one playing",
after["videoId"] == before["videoId"], f"{before['videoId']} -> {after['videoId']}")
c.ok(f"{label}: and the same step",
after["index"] == before["index"], f"{before['index']} -> {after['index']}")
c.ok(f"{label}: the scroll position is the one that step sits at",
abs(after["scrollTop"] - after["expected"]) <= 1, str(after))
c.ok(f"{label}: so the step fills the screen rather than straddling two",
after["activeTop"] == 0, str(after["activeTop"]))
c.ok(f"{label}: every slide is exactly a screenful",
after["offHeight"] == [], str(after["offHeight"]))
return after
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",
])
page = browser.new_page(viewport=PORTRAIT)
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(800)
print("\nseven videos into the reel")
swipe_to(page, 7)
start = page.evaluate(WHERE)
c.ok("that is where the reel is", start["index"] == 7, str(start))
c.ok("and the slides are a screenful each to begin with",
start["offHeight"] == [], str(start["offHeight"]))
print("\nturning it sideways")
land = rotate(page, c, LANDSCAPE, "landscape")
c.ok("the slides took the new height", land["height"] == 430, str(land["height"]))
print("\nand back upright")
port = rotate(page, c, PORTRAIT, "portrait")
c.ok("the slides took the height back", port["height"] == 930, str(port["height"]))
print("\nswiping still works once it has settled")
page.evaluate(GO, port["index"] + 1)
page.wait_for_timeout(700)
moved = page.evaluate(WHERE)
c.ok("a real swipe after a rotation moves the reel on",
moved["index"] == port["index"] + 1, str(moved["index"]))
print("\nrotating twice in a row, as a wrist does")
page.set_viewport_size(LANDSCAPE)
page.wait_for_timeout(90)
page.set_viewport_size(PORTRAIT)
page.wait_for_timeout(1500)
twice = page.evaluate(WHERE)
c.ok("the reel is where it was left", twice["index"] == moved["index"] and
twice["videoId"] == moved["videoId"], str(twice))
c.ok("and sitting on its snap point",
abs(twice["scrollTop"] - twice["expected"]) <= 1, str(twice))
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())

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Turning the phone moves the picture once, not a dozen times.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_rotate_smooth.py
Its sibling smoke_rotate.py asks that a turn keep the same video. This one
asks what the turn looks like while it happens.
A turn is not one shape change: the viewport passes through every shape
between the one it left and the one it lands in, and each one re-letterboxes
the picture somewhere else. Painted, that run of jumps is the stutter. So the
slides are held at the size the turn is heading for from its first frame: the
picture steps straight into the position and size it will keep, and stays
there -- no fade, no intermediate shapes, nothing dimmed.
A viewport that only changes height is the browser's chrome sliding away, not
a wrist, and must not be pinned: every scroll on a phone does that.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
PORTRAIT = {"width": 430, "height": 930}
LANDSCAPE = {"width": 930, "height": 430}
# The shapes a viewport goes through on its way round.
TURN = [{"width": 560, "height": 800}, {"width": 700, "height": 660},
{"width": 830, "height": 520}, LANDSCAPE]
# The chrome sliding away: height only, and nothing announced it.
CHROME = {"width": 430, "height": 830}
STATUS = {"online": True, "channels": [{
"id": CHANNEL, "name": "Fixture Clips",
"description": "A channel that only exists for this test.", "options": []}]}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
# Samples every frame: how much of the reel is on screen, what shape the
# picture has, and whether it is still where it belongs.
WATCH = """() => {
window.__seen = [];
window.__watch = true;
const scroller = document.getElementById('feed-scroll');
const tick = function() {
const active = document.querySelector('.feed-slide.is-active');
const video = active ? active.querySelector('.feed-video') : null;
const box = video ? video.getBoundingClientRect() : null;
window.__seen.push({
opacity: Number(getComputedStyle(scroller).opacity),
pinned: document.getElementById('feed-view').classList.contains('is-turning'),
shape: box ? [Math.round(box.width), Math.round(box.height)] : null,
top: active ? Math.round(active.getBoundingClientRect().top) : null,
paused: video ? video.paused : null
});
if (window.__watch) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}"""
STOP = "() => { window.__watch = false; return window.__seen; }"
REST = """() => {
const scroller = document.getElementById('feed-scroll');
const style = getComputedStyle(scroller);
return {
opacity: Number(style.opacity),
snap: style.scrollSnapType,
overflow: style.overflowY,
shape: (function() {
const box = document.querySelector('.feed-slide.is-active .feed-video');
const rect = box ? box.getBoundingClientRect() : null;
return rect ? [Math.round(rect.width), Math.round(rect.height)] : null;
})(),
turning: document.getElementById('feed-view').classList.contains('is-turning'),
videoId: App.state.feedActiveVideoId,
index: App.state.feedActiveIndex,
scrollTop: Math.round(scroller.scrollTop),
expected: App.state.feedActiveIndex * scroller.clientHeight
};
}"""
GO = """(step) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = step * scroller.clientHeight;
}"""
# The container's own re-anchor, on a position measured against the height
# from before the turn, arriving while the new one is already in place.
STALE = """(oldHeight) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = App.state.feedActiveIndex * oldHeight;
}"""
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 items():
return [{"id": f"vid-{i}", "title": f"clip {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
def shape_changes(seen):
"""The frames where the picture changed shape, with how lit they were."""
out = []
for a, b in zip(seen, seen[1:]):
if a["shape"] and b["shape"] and a["shape"] != b["shape"]:
out.append({"from": a["shape"], "to": b["shape"], "opacity": b["opacity"]})
return out
def do_turn(page, sizes, old_height, announced=True):
page.evaluate(WATCH)
page.wait_for_timeout(120)
if announced:
page.evaluate("() => window.dispatchEvent(new Event('orientationchange'))")
for i, size in enumerate(sizes):
page.set_viewport_size(size)
if i == 1:
page.evaluate(STALE, old_height)
page.wait_for_timeout(70)
page.wait_for_timeout(1400)
return page.evaluate(STOP)
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",
])
page = browser.new_page(viewport=PORTRAIT)
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(800)
for step in (1, 2, 3):
page.evaluate(GO, step)
page.wait_for_timeout(250)
page.wait_for_timeout(800)
before = page.evaluate(REST)
print("\nturning it sideways, one shape at a time")
seen = do_turn(page, TURN, PORTRAIT["height"])
changes = shape_changes(seen)
rest = page.evaluate(REST)
c.ok("the picture moved once, not through every shape on the way",
len(changes) == 1, str(changes))
c.ok("and the one step it took was straight into the size it keeps",
bool(changes) and changes[0]["to"] == rest["shape"],
f"{changes} -> {rest['shape']}")
c.ok("nothing was dimmed to cover it",
all(s["opacity"] == 1 for s in seen),
str(min(s["opacity"] for s in seen)))
c.ok("the reel stayed pinned to its step the whole way",
all(s["top"] in (None, 0) for s in seen),
str(sorted({s["top"] for s in seen})))
c.ok("the slides were held only for the turn, not after it",
0 < len([s for s in seen if s["pinned"]]) < len(seen) * 0.75,
f"{len([s for s in seen if s['pinned']])} of {len(seen)} frames")
c.ok("the clip never paused for it",
not any(s["paused"] for s in seen), "")
c.ok("and the feed is no longer marked as turning", not rest["turning"], str(rest))
c.ok("snapping is back on", "y" in rest["snap"], str(rest["snap"]))
c.ok("and so is scrolling", rest["overflow"] == "scroll", str(rest["overflow"]))
c.ok("on the same video it was on",
rest["videoId"] == before["videoId"],
f"{before['videoId']} -> {rest['videoId']}")
c.ok("sitting on its snap point",
abs(rest["scrollTop"] - rest["expected"]) <= 1, str(rest))
print("\nand back upright")
seen = do_turn(page, list(reversed(TURN[:-1])) + [PORTRAIT], LANDSCAPE["height"])
changes = shape_changes(seen)
back = page.evaluate(REST)
c.ok("one step on the way back too", len(changes) == 1, str(changes))
c.ok("into the size it keeps",
bool(changes) and changes[0]["to"] == back["shape"],
f"{changes} -> {back['shape']}")
c.ok("still the same video", back["videoId"] == before["videoId"], str(back))
print("\nthe browser's chrome sliding away is not a turn")
seen = do_turn(page, [CHROME], PORTRAIT["height"], announced=False)
c.ok("the slides were never pinned for it",
not any(s["pinned"] for s in seen), "")
page.set_viewport_size(PORTRAIT)
page.wait_for_timeout(1200)
chrome = page.evaluate(REST)
c.ok("and it is still on the same video",
chrome["videoId"] == before["videoId"], str(chrome))
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())

View File

@@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""A whole search -- source, channel, filters, query -- can be kept and come back to.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_saved_searches.py
Both the status and the listing are fixtures, so the test owns what "new"
means: it changes the ids the listing hands back and then asks the client how
many of them it has never shown.
"""
import json
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [{
"id": "sort",
"title": "Sort",
"options": [
{"id": "latest", "title": "Latest"},
{"id": "views", "title": "Most viewed"},
],
}],
}],
}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.removeItem('searchHistory');
localStorage.removeItem('savedSearches');
}"""
SAVED = """() => JSON.parse(localStorage.getItem('savedSearches') || '[]')"""
BADGE = """() => {
const badge = document.getElementById('saved-searches-badge');
return { hidden: badge.hidden, text: badge.textContent };
}"""
ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
name: row.querySelector('.saved-row-name') ? row.querySelector('.saved-row-name').textContent : '',
count: row.querySelector('.saved-count').textContent,
quiet: row.querySelector('.saved-count').classList.contains('is-quiet'),
meta: row.querySelector('.saved-row-meta').textContent,
stamp: row.querySelector('.saved-row-stamp').textContent
}))"""
MARK_ROWS = """() => {
// A mark on the elements themselves: a rebuilt row cannot carry it.
const rows = Array.from(document.querySelectorAll('#saved-panel .saved-row'));
rows.forEach((row, i) => { row.dataset.probe = `p${i}`; });
return rows.length;
}"""
PROBES = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
probe: row.dataset.probe || '',
count: row.querySelector('.saved-count').textContent,
fresh: row.classList.contains('is-new'),
stamp: row.querySelector('.saved-row-stamp').textContent
}))"""
READ_BTN = """() => {
const button = document.getElementById('saved-read-btn');
return { disabled: button.disabled, text: button.textContent };
}"""
IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')"""
PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')"""
SESSION = """() => {
const s = JSON.parse(localStorage.getItem('session'));
return { channel: s.channel.id, sort: s.options.sort ? s.options.sort.id : null };
}"""
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()
asked = []
# Which ids the listing hands back. Shifting this window is how the test
# says "three new videos were uploaded".
offset = {"at": 0, "fail": False}
# How many videos a page comes back with, whatever was asked for: real
# sources cut pages their own way and send what they send.
size = {"n": 12}
def items(query):
start = offset["at"]
return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 1, "isLive": False, "tags": []}
for i in range(start, start + size["n"])]
with sync_playwright() as p:
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": 1180, "height": 920})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
def status(route):
route.fulfill(status=200, content_type="application/json", body=json.dumps(STATUS))
def listing(route):
try:
body = json.loads(route.request.post_data or "{}")
except Exception:
body = {}
asked.append(body)
if offset["fail"]:
route.fulfill(status=500, content_type="application/json", body="{}")
return
route.fulfill(status=200, content_type="application/json", body=json.dumps(
{"items": items(body.get("query", "")), "pageInfo": {"hasNextPage": False}}))
page.route("**/api/status", status)
page.route("**/api/videos", listing)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(400)
print("\nthe feature is there at all")
c.ok("the module loaded", page.evaluate("() => typeof App.savedSearches.saveCurrent") == 'function')
c.ok("with nothing saved, the header count is out", page.evaluate(BADGE)["hidden"] is True)
c.ok("and the bookmark is unlit", page.evaluate(IS_SAVED) is False)
print("\nkeeping the search that is on screen")
page.fill("#search-input", "")
page.type("#search-input", "sauna", delay=30)
page.wait_for_timeout(900)
page.click("#search-save-btn")
page.wait_for_timeout(200)
saved = page.evaluate(SAVED)
c.ok("one search was kept", len(saved) == 1, str(saved))
entry = saved[0] if saved else {}
c.ok("with the words in it", entry.get("query") == "sauna", str(entry.get("query")))
c.ok("the channel it was run against", entry.get("channelId") == CHANNEL, str(entry.get("channelId")))
c.ok("the source", entry.get("server") == SERVER, str(entry.get("server")))
c.ok("and the filters as they stood", entry.get("options") == {"sort": "latest"}, str(entry.get("options")))
c.ok("it is named after the search", entry.get("name") == "sauna", str(entry.get("name")))
c.ok("what is on screen counts as seen", len(entry.get("seenIds") or []) == 12, str(len(entry.get("seenIds") or [])))
c.ok("so nothing is new yet", entry.get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True)
c.ok("and the bookmark is lit", page.evaluate(IS_SAVED) is True)
print("\nthe bookmark is about this search, not just this channel")
page.fill("#search-input", "")
page.type("#search-input", "pool", delay=30)
page.wait_for_timeout(300)
c.ok("typing something else puts it out", page.evaluate(IS_SAVED) is False)
page.fill("#search-input", "")
page.type("#search-input", "sauna", delay=30)
page.wait_for_timeout(300)
c.ok("coming back to it lights it again", page.evaluate(IS_SAVED) is True)
print("\nand a filter is part of it too")
page.evaluate("""() => {
const s = JSON.parse(localStorage.getItem('session'));
s.options.sort = { id: 'views', title: 'Most viewed' };
localStorage.setItem('session', JSON.stringify(s));
App.ui.renderMenu();
}""")
page.wait_for_timeout(200)
c.ok("a different sort is a different search", page.evaluate(IS_SAVED) is False)
print("\nthree videos turn up while nobody is looking")
offset["at"] = 3
asked.clear()
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
c.ok("the check asked for the saved search, not what is on screen",
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
c.ok("three of them are new", saved[0].get("newCount") == 3, str(saved[0].get("newCount")))
badge = page.evaluate(BADGE)
c.ok("the header says so", badge["hidden"] is False and badge["text"] == "3", str(badge))
print("\na busy day")
offset["at"] = 40
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
badge = page.evaluate(BADGE)
c.ok("every one of them is new", saved[0].get("newCount") == 12, str(saved[0].get("newCount")))
c.ok("the badge stops counting at 9+", badge["text"] == "9+", str(badge))
print("\nonce a day, not once a page view")
asked.clear()
page.evaluate("() => App.savedSearches.check()")
page.wait_for_timeout(500)
c.ok("a search checked a moment ago is left alone", asked == [], str(asked))
page.evaluate("""() => {
const all = JSON.parse(localStorage.getItem('savedSearches'));
all[0].lastCheckedAt = Date.now() - 2 * 24 * 60 * 60 * 1000;
localStorage.setItem('savedSearches', JSON.stringify(all));
}""")
page.evaluate("() => App.savedSearches.check()")
page.wait_for_timeout(900)
c.ok("one last checked two days ago is asked about",
any(b.get("query") == "sauna" for b in asked), str(asked))
print("\nthe list")
page.click("#saved-searches-btn")
page.wait_for_timeout(300)
rows = page.evaluate(ROWS)
c.ok("it opens", page.evaluate(PANEL_OPEN) is True)
c.ok("with the saved search in it", len(rows) == 1 and rows[0]["name"] == "sauna", str(rows))
c.ok("carrying its count", rows[0]["count"] == "9+" and rows[0]["quiet"] is False, str(rows[0]))
c.ok("and saying where it points",
"Fixture Clips" in rows[0]["meta"] and "Latest" in rows[0]["meta"] and "sauna" in rows[0]["meta"],
rows[0]["meta"])
print("\ntapping it puts the whole search back")
asked.clear()
page.click("#saved-panel .saved-row-main")
page.wait_for_timeout(1200)
c.ok("the list closes", page.evaluate(PANEL_OPEN) is False)
c.ok("the words are back in the box",
page.evaluate("() => document.getElementById('search-input').value") == "sauna")
session = page.evaluate(SESSION)
c.ok("the filter is back as it was", session == {"channel": CHANNEL, "sort": "latest"}, str(session))
c.ok("and that is what was fetched",
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
saved = page.evaluate(SAVED)
c.ok("opening it is viewing it, so the count goes out",
saved[0].get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True, str(saved[0]))
c.ok("and what it showed is the new mark",
"vid-40" in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3]))
c.ok("so the bookmark is lit again", page.evaluate(IS_SAVED) is True)
print("\nnothing new, said quietly")
page.click("#saved-searches-btn")
page.wait_for_timeout(300)
rows = page.evaluate(ROWS)
c.ok("no number where there is no news",
rows[0]["quiet"] is True and "nothing new" in rows[0]["stamp"].lower(), str(rows[0]))
print("\nrenaming one")
page.click("#saved-panel .saved-tool[data-act='rename']")
page.wait_for_timeout(250)
page.fill("#saved-panel .saved-rename", "Sauna, newest first")
page.keyboard.press("Enter")
page.wait_for_timeout(300)
saved = page.evaluate(SAVED)
c.ok("the new name is kept", saved[0].get("name") == "Sauna, newest first", str(saved[0].get("name")))
c.ok("and shown", page.evaluate(ROWS)[0]["name"] == "Sauna, newest first")
print("\nsaving a second one from the list itself")
page.evaluate("() => App.ui.savedSearches.close()")
page.fill("#search-input", "")
page.type("#search-input", "steam", delay=30)
page.wait_for_timeout(900)
page.click("#saved-searches-btn")
page.wait_for_timeout(250)
page.click("#saved-add-btn")
page.wait_for_timeout(300)
saved = page.evaluate(SAVED)
c.ok("there are two now", len(saved) == 2, str([s.get("name") for s in saved]))
c.ok("the new one is first and named after its query",
saved[0].get("query") == "steam", str(saved[0].get("name")))
c.ok("and it opens ready to be named",
page.evaluate("() => !!document.querySelector('#saved-panel .saved-rename')"))
page.keyboard.press("Escape")
page.wait_for_timeout(200)
print("\nremoving one asks first")
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
page.wait_for_timeout(250)
c.ok("the row asks", page.evaluate(
"() => !!document.querySelector('#saved-panel .saved-confirm')"))
page.click("#saved-panel .saved-confirm-btn:not(.is-danger)")
page.wait_for_timeout(250)
c.ok("keeping keeps it", len(page.evaluate(SAVED)) == 2)
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
page.wait_for_timeout(250)
page.click("#saved-panel .saved-confirm-btn.is-danger")
page.wait_for_timeout(250)
saved = page.evaluate(SAVED)
c.ok("removing removes it", len(saved) == 1 and saved[0].get("query") == "sauna", str(saved))
print("\na source that does not answer")
page.evaluate("""() => {
const all = JSON.parse(localStorage.getItem('savedSearches'));
all[0].newCount = 0;
localStorage.setItem('savedSearches', JSON.stringify(all));
}""")
offset["fail"] = True
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(1200)
saved = page.evaluate(SAVED)
c.ok("is not a search with nothing new in it",
saved[0].get("unreachable") is True and saved[0].get("newCount") == 0, str(saved[0]))
rows = page.evaluate(ROWS)
c.ok("and the row says as much", "could not be checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
offset["fail"] = False
print("\nacross a reload")
page.evaluate("""() => {
const all = JSON.parse(localStorage.getItem('savedSearches'));
all[0].newCount = 4;
all[0].unreachable = false;
all[0].lastCheckedAt = Date.now();
localStorage.setItem('savedSearches', JSON.stringify(all));
}""")
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(600)
badge = page.evaluate(BADGE)
c.ok("the count is on screen straight from storage",
badge["hidden"] is False and badge["text"] == "4", str(badge))
c.ok("the search survived too", len(page.evaluate(SAVED)) == 1)
print("\na source that sends more than the page it was asked for")
# sxyprn answers a page of 12 with 30. Everything past the twelfth was
# never shown to the reader and was never new either -- it sat on the
# same page all along -- so none of it may end up on the badge.
size["n"] = 30
page.evaluate("""async () => {
App.videos.resetGrid();
await App.videos.loadVideos();
App.savedSearches.markViewed(App.savedSearches.getAll()[0].id);
}""")
page.wait_for_timeout(800)
saved = page.evaluate(SAVED)
c.ok("the mark holds the whole page the source sent, not the one asked for",
len(saved[0].get("seenIds") or []) == 30, str(len(saved[0].get("seenIds") or [])))
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
badge = page.evaluate(BADGE)
c.ok("the same page, checked again, is not news",
saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
c.ok("so the badge stays out instead of sticking on a number",
badge["hidden"] is True, str(badge))
c.ok("and nothing is counted anywhere else either",
page.evaluate("() => App.savedSearches.totalNew()") == 0)
print("\nnews on such a source still counts")
offset["at"] = offset["at"] + 5
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
c.ok("five turning up is five", saved[0].get("newCount") == 5, str(saved[0].get("newCount")))
print("\nmarking the whole list read")
page.click("#saved-searches-btn")
page.wait_for_timeout(300)
c.ok("the button is offered while something is new",
page.evaluate(READ_BTN)["disabled"] is False, str(page.evaluate(READ_BTN)))
asked.clear()
page.click("#saved-read-btn")
page.wait_for_timeout(1400)
saved = page.evaluate(SAVED)
badge = page.evaluate(BADGE)
c.ok("every count goes out", all(entry.get("newCount") == 0 for entry in saved), str(saved))
c.ok("and the header count with them", badge["hidden"] is True, str(badge))
c.ok("the mark moves on to what the source lists now, so it stays out",
f"vid-{offset['at']}" in (saved[0].get("seenIds") or []) and
f"vid-{offset['at'] - 5}" not in (saved[0].get("seenIds") or []),
str((saved[0].get("seenIds") or [])[:3]))
c.ok("which took asking the source", any(b.get("query") == "sauna" for b in asked), str(asked))
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
c.ok("a check straight after finds nothing new",
saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
c.ok("and the button turns itself off with nothing left to clear",
page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN)))
print("\na check writes into the list without rebuilding it")
c.ok("the list is open with a row in it", page.evaluate(MARK_ROWS) == 1)
offset["at"] = offset["at"] + 2
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(1000)
rows = page.evaluate(PROBES)
c.ok("the row is the same element it was before the check",
len(rows) == 1 and rows[0]["probe"] == "p0", str(rows))
c.ok("with the count written into it where it stood",
rows[0]["count"] == "2", str(rows[0]["count"]))
c.ok("and the time of the check with it",
"checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
c.ok("nothing animated itself back in", rows[0]["fresh"] is False, str(rows[0]))
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())

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Searches are kept, and offered back under the box as you type.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_search_history.py
The listing is a fixture and records what it was asked for, so "the search
actually ran" is something the test can see rather than infer.
"""
import json
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.removeItem('searchHistory');
}"""
HISTORY = """() => JSON.parse(localStorage.getItem('searchHistory') || '[]')"""
ROWS = """() => Array.from(document.querySelectorAll('#search-suggest .search-suggest-row'))
.map((row) => row.dataset.query)"""
OPEN = """() => !document.getElementById('search-suggest').hidden"""
ACTIVE = """() => {
const row = document.querySelector('#search-suggest .search-suggest-row.is-active');
return row ? row.dataset.query : null;
}"""
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 items(query):
return [{"id": f"fix-{query}-{i}", "title": f"{query or 'anything'} {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": "test",
"duration": 1, "isLive": False, "tags": []} for i in range(12)]
def wait_for_query(page, asked, query, ms=4000):
"""The search may go out on the request already in flight rather than a new
one of its own (the box searches as you type, so there usually is one), so
what is waited for is the query reaching the server, not a fresh request."""
for _ in range(ms // 100):
if query in asked:
return True
page.wait_for_timeout(100)
return query in asked
def type_into(page, text):
"""Types a query the way a reader would, a character at a time."""
page.click("#search-input")
page.fill("#search-input", "")
page.type("#search-input", text, delay=40)
def main():
c = Checks()
asked = []
with sync_playwright() as p:
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": 1100, "height": 900})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
# With `hold` on, listing requests are recorded but left unanswered, so
# the test can act while one is still out.
hold = {"on": False, "parked": []}
def answer(route, query):
route.fulfill(status=200, content_type="application/json", body=json.dumps(
{"items": items(query), "pageInfo": {"hasNextPage": False}}))
def listing(route):
try:
body = json.loads(route.request.post_data or "{}")
except Exception:
body = {}
query = body.get("query", "")
asked.append(query)
if hold["on"]:
hold["parked"].append((route, query))
return
answer(route, query)
def release():
hold["on"] = False
while hold["parked"]:
route, query = hold["parked"].pop(0)
answer(route, query)
page.route("**/api/videos", listing)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(500)
print("\nsearching for something")
type_into(page, "hotel")
page.wait_for_timeout(1200)
stored = page.evaluate(HISTORY)
c.ok("the search was kept", "hotel" in stored, str(stored))
c.ok("and the typing on the way to it was not",
stored == ["hotel"], str(stored))
c.ok("the search itself ran", asked and asked[-1] == "hotel", str(asked[-3:]))
print("\nand for something else, then reloading")
type_into(page, "sauna")
page.wait_for_timeout(1200)
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
stored = page.evaluate(HISTORY)
c.ok("both searches survived the reload",
stored == ["sauna", "hotel"], str(stored))
print("\nwith a history to draw on")
page.evaluate("""() => localStorage.setItem('searchHistory', JSON.stringify([
'hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room',
'hot spring', 'cold plunge'
]))""")
page.click("#search-input")
page.wait_for_timeout(300)
rows = page.evaluate(ROWS)
c.ok("the box offers the recent ones when empty", page.evaluate(OPEN) is True)
c.ok("five of them, newest first",
rows == ['hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room'], str(rows))
print("\ntyping narrows them down")
page.type("#search-input", "hot", delay=40)
page.wait_for_timeout(200)
rows = page.evaluate(ROWS)
print(f" (offered: {rows})")
c.ok("only the ones it appears in are left",
set(rows) == {'hot tub', 'hotel room', 'hot spring', 'shot in the dark'}, str(rows))
c.ok("the ones it starts come first",
rows[:3] == ['hot tub', 'hotel room', 'hot spring'], str(rows))
c.ok("and the one it is buried in comes last",
rows[-1] == 'shot in the dark', str(rows))
c.ok("the part that matched is picked out",
page.evaluate("""() => {
const hit = document.querySelector('#search-suggest .search-suggest-hit');
return !!hit && hit.textContent.toLowerCase() === 'hot';
}"""))
print("\nwhat is already typed in full is not a suggestion")
page.fill("#search-input", "")
page.type("#search-input", "sauna", delay=20)
page.wait_for_timeout(200)
rows = page.evaluate(ROWS)
c.ok("it offers itself back to nobody",
'sauna' not in rows and page.evaluate(OPEN) is False, str(rows))
print("\ntaking one with the keyboard")
page.fill("#search-input", "")
page.type("#search-input", "hot", delay=20)
page.wait_for_timeout(200)
page.keyboard.press("ArrowDown")
first = page.evaluate(ACTIVE)
page.keyboard.press("ArrowDown")
second = page.evaluate(ACTIVE)
c.ok("the arrows walk the list", first == 'hot tub' and second == 'hotel room',
f"{first} then {second}")
asked.clear()
page.keyboard.press("Enter")
page.wait_for_timeout(300)
c.ok("enter takes the one highlighted",
page.evaluate("() => document.getElementById('search-input').value") == 'hotel room')
c.ok("and searches for it", wait_for_query(page, asked, 'hotel room'), str(asked))
c.ok("the list is put away", page.evaluate(OPEN) is False)
print("\ntaking one with the pointer")
page.fill("#search-input", "")
page.click("#search-input")
page.type("#search-input", "st", delay=20)
page.wait_for_timeout(250)
asked.clear()
rows = page.evaluate(ROWS)
page.click(f"#search-suggest .search-suggest-row:nth-of-type(1)")
page.wait_for_timeout(300)
c.ok("the one clicked is what gets searched for",
page.evaluate("() => document.getElementById('search-input').value") == rows[0]
and wait_for_query(page, asked, rows[0]), f"{rows} then {asked}")
print("\nescape")
page.fill("#search-input", "")
page.click("#search-input")
page.wait_for_timeout(250)
c.ok("the list is up again", page.evaluate(OPEN) is True)
page.keyboard.press("Escape")
page.wait_for_timeout(150)
c.ok("escape puts it away", page.evaluate(OPEN) is False)
print("\nchoosing one while the box's own search is still out")
# The box searches as you type: choose a suggestion at the wrong moment
# and the search behind it used to be dropped, leaving the results on
# the query before it.
page.fill("#search-input", "")
page.click("#search-input")
hold["on"] = True
asked.clear()
page.type("#search-input", "hot", delay=20)
page.wait_for_timeout(700) # long enough for the typed search to go out
c.ok("the typed search is on the wire", asked == ['hot'], str(asked))
page.keyboard.press("ArrowDown")
chosen = page.evaluate(ACTIVE)
page.keyboard.press("Enter")
landed = wait_for_query(page, asked, chosen, 3000)
release()
page.wait_for_timeout(800)
c.ok(f"the chosen one ({chosen}) is searched for all the same", landed, str(asked))
c.ok("and its results are what the grid ends up with",
page.evaluate("() => (App.state.loadedVideos[0] || {}).id || ''")
.startswith(f"fix-{chosen}"),
page.evaluate("() => (App.state.loadedVideos[0] || {}).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())

348
tests/smoke_split_reuse.py Normal file
View File

@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Splitting a panel keeps the video that is already playing, rather than
fetching it again.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_split_reuse.py
The clip server counts what is asked of it, which is the whole point: a split
renumbers every step and rebuilds the slides, and the question is whether the
bytes already in hand survive that. The listing is a fixture for the same
reason as everywhere else -- so the answer is about the feed, not a CDN.
Each panel's <video> is tagged, so a rebuilt panel is told apart from a reused
one by the element rather than by what it is showing.
"""
import base64
import http.server
import json
import socketserver
import sys
import threading
import time
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
PER_PAGE = 12
PAGES = 8
MEDIA_DELAY_MS = 300 # what a clip costs if it isn't already loading
# One second of black, 64x64, h264 -- a real file, because the panels have to
# decode a frame from it. Same blob as smoke_open.py.
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(fetched):
"""Serves the fixture clip, counting who asked for what."""
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_GET(self):
fetched[self.path] = fetched.get(self.path, 0) + 1
time.sleep(MEDIA_DELAY_MS / 1000)
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)
def do_HEAD(self):
fetched[self.path] = fetched.get(self.path, 0) + 1
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([]));
}"""
# Which videos were asked to resolve their formats. Prefilled formats make the
# answer immediate, but the asking is what says when the feed goes looking.
SPY = """() => {
window.__resolved = [];
const real = App.videos.ensureFormats;
App.videos.ensureFormats = function(video) {
if (video && video.id) window.__resolved.push(String(video.id));
return real.apply(this, arguments);
};
}"""
PANES = """() => Array.from(document.querySelectorAll('.feed-slide')).map((slide) => ({
step: Number(slide.dataset.step),
loaded: Array.from(slide.querySelectorAll('.feed-pane'))
.map((pane) => pane.classList.contains('is-loaded')),
ids: Array.from(slide.querySelectorAll('.feed-pane')).map((pane) => pane.dataset.videoId),
}))"""
WATCH = """() => {
window.__t0 = performance.now();
window.__started = {};
document.addEventListener('playing', (e) => {
const pane = e.target.closest && e.target.closest('.feed-pane');
if (!pane) return;
const id = pane.dataset.videoId;
if (!(id in window.__started)) window.__started[id] = Math.round(performance.now() - window.__t0);
}, true);
}"""
SWIPE = """() => { const s = document.getElementById('feed-scroll'); s.scrollTop += s.clientHeight; }"""
CLICK = """(sel) => {
const el = document.querySelector(sel);
if (!el) return 'missing';
el.click(); // dispatched, so the pointer never wakes the HUD
return 'ok';
}"""
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 make_items(page_no, media):
"""One page of the listing, formats already resolved so nothing but the
media itself stands between a panel and its first frame."""
start = (page_no - 1) * PER_PAGE
items = []
for i in range(PER_PAGE):
n = start + i
url = f"{media}/v{n}.mp4"
items.append({
"id": f"fix-{n}",
"title": f"Fixture {n}",
"url": "https://example.test/watch/%d" % n,
"channel": "test",
"duration": 1,
"thumb": "",
"tags": [],
"meta": {
"url": url,
"http_headers": {},
"isLive": False,
"formats": [{"url": url, "ext": "mp4", "height": 240, "protocol": "https",
"vcodec": "avc1", "acodec": "none"}],
},
})
return items
# What a panel is holding: the element itself (tagged), where it has got to,
# and whether it still has its source.
MARK = """() => {
document.querySelectorAll('.feed-pane').forEach((pane, i) => {
const video = pane.querySelector('.feed-video');
if (video && !video.__tag) video.__tag = 'v' + i + '-' + pane.dataset.videoId;
});
}"""
PANELS = """() => Array.from(document.querySelectorAll('.feed-slide.is-active .feed-pane')).map((pane) => {
const video = pane.querySelector('.feed-video');
return {
id: pane.dataset.videoId,
tag: video ? (video.__tag || null) : null,
t: video ? Math.round(video.currentTime * 100) / 100 : null,
paused: video ? video.paused : null,
buffered: video && video.buffered.length ? Math.round(video.buffered.end(0) * 100) / 100 : 0,
ready: video ? video.readyState : -1,
loaded: pane.classList.contains('is-loaded'),
};
})"""
CLICK = """(sel) => {
const el = document.querySelector(sel);
if (!el) return 'missing';
el.click();
return 'ok';
}"""
def main():
c = Checks()
fetched = {}
httpd = make_server(fetched)
media = f"http://127.0.0.1:{httpd.server_address[1]}"
parked = []
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=768",
])
page = browser.new_page(viewport={"width": 800, "height": 900})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
def answer():
while parked:
route = parked.pop(0)
try:
body = json.loads(route.request.post_data or "{}")
except Exception:
body = {}
page_no = int(body.get("page", 1) or 1)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"items": make_items(page_no, media) if page_no <= PAGES else [],
"pageInfo": {"hasNextPage": page_no < PAGES},
}))
def settle(ms):
for _ in range(max(1, ms // 100)):
page.wait_for_timeout(100)
answer()
page.route("**/api/videos", lambda route: parked.append(route))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
settle(1500)
page.wait_for_selector(".video-card", timeout=30000)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
settle(4000)
page.evaluate(MARK)
before = page.evaluate(PANELS)
watched = before[0]["id"]
print(f" (watching {watched}, at {before[0]['t']}s)")
c.ok("one panel to start with", len(before) == 1, str(before))
c.ok("and it is playing", before[0]["paused"] is False, str(before[0]))
asked_before = dict(fetched)
print("\nsplitting it in two")
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane .feed-pane-split-right")
settle(2500)
after = page.evaluate(PANELS)
kept = [pane for pane in after if pane["id"] == watched]
print(f" (panels now: {[(x['id'], x['tag'], x['t']) for x in after]})")
c.ok("there are two panels", len(after) == 2, str(after))
c.ok("the video being watched is still one of them", len(kept) == 1, str(after))
c.ok("in the same element it was playing in -- not a new one",
kept and kept[0]["tag"] == before[0]["tag"], f"{kept and kept[0]['tag']} vs {before[0]['tag']}")
# Not currentTime: the fixture clip is a second long and the feed loops
# it, so where it has got to says nothing. What says it never reloaded
# is that it still has decoded frames and a buffer.
c.ok("with the frames it had already decoded, not back at nothing",
kept and kept[0]["ready"] >= 2 and kept[0]["buffered"] > 0, str(kept))
c.ok("and it is still playing", kept and kept[0]["paused"] is False, str(kept))
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
watched_path = "/v0.mp4"
c.ok("its bytes were not fetched a second time",
watched_path not in again,
f"asked for again: {sorted(again)}")
print("\nand splitting once more")
page.evaluate(MARK) # tag first, then read: an untagged pane
marks = page.evaluate(PANELS) # on both sides would compare None to None
asked_before = dict(fetched)
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(1) .feed-pane-split-down")
settle(2500)
three = page.evaluate(PANELS)
held = {pane["id"]: pane for pane in three}
c.ok("three panels now", len(three) == 3, str(three))
expected = [m for m in marks if m["id"] in held]
carried = [m for m in expected if held[m["id"]]["tag"] == m["tag"]]
c.ok("both panels that were already playing carried their element over",
expected and all(m["tag"] for m in expected) and len(carried) == len(expected),
str(three))
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
c.ok("and neither was fetched again",
not any(f"/v{i}.mp4" in again for i in (0, 1)), f"asked for again: {sorted(again)}")
print("\nclosing one of them")
asked_before = dict(fetched)
page.evaluate(MARK)
marks = page.evaluate(PANELS)
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(2) .feed-pane-close")
settle(2500)
left = page.evaluate(PANELS)
held = {pane["id"]: pane for pane in left}
c.ok("two panels left", len(left) == 2, str(left))
survivors = [m for m in marks if m["id"] in held]
c.ok("the survivors kept their elements",
survivors and all(m["tag"] and held[m["id"]]["tag"] == m["tag"] for m in survivors),
str(left))
c.ok("still playing", all(pane["paused"] is False for pane in left), str(left))
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
c.ok("and nothing that was already loaded was fetched again",
not any(f"/v{i}.mp4" in again for i in (0, 1, 2)), f"asked for again: {sorted(again)}")
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())

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

188
tests/unit_direct_probe.js Normal file
View File

@@ -0,0 +1,188 @@
#!/usr/bin/env node
/* The direct-playability probe, tested without a browser.
*
* node tests/unit_direct_probe.js
*
* The probe decides whether the player may skip the proxy and let the browser
* fetch a CDN URL itself. Getting that wrong is not a slow video but a wrong
* one: eporner's CDN answers a browser-origin Referer with a 302 to a
* one-minute "watch this at our site instead" clip, served 206 with
* `Access-Control-Allow-Origin: *`, so "bytes arrived" was satisfied by a
* stand-in and the player switched to it mid-video. What settles it is whether
* the direct route hands back the same media the proxied one does, which is
* two stubbed fetches to check -- no server, no Chromium, no network.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
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,
Response,
AbortController,
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);
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
vm.runInContext(
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', 'videos.js'), 'utf8'),
context, 'videos.js');
const { probeDirect } = sandbox.App.videos;
let failed = 0;
const ok = (label, cond, detail) => {
if (!cond) failed++;
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
};
// Stands in for both routes. `direct` is what the CDN hands the browser,
// `proxied` what /api/stream reports -- either given as a byte count, or null
// for a response that states no length at all. Every call is recorded so the
// test can check what was asked of whom.
let calls = [];
const stubFetch = function(direct, proxied) {
calls = [];
sandbox.fetch = async (url, opts) => {
calls.push({ url: String(url), method: (opts && opts.method) || 'GET' });
if (String(url).indexOf('/api/stream') === 0) {
const headers = proxied === null ? {} : { 'Content-Length': String(proxied) };
return new Response('', { status: 200, headers: headers });
}
const headers = direct === null ? {} : { 'Content-Length': String(direct) };
return new Response(new Uint8Array(16), { status: 206, headers: headers });
};
};
// A fresh host each time: the probe caches its answer per host for the session,
// which is the point of it, so reusing one would answer from the cache.
let hostSeq = 0;
const sourceOn = (host) => ({
url: `https://${host}/v4/token/10299874-1080p.mp4`,
referer: 'https://www.eporner.com/',
headers: { Referer: 'https://www.eporner.com/' },
});
const freshSource = () => sourceOn(`cdn${++hostSeq}.example.com`);
const run = async () => {
console.log('\nwhat the two routes say about the same URL');
stubFetch(486933813, 486933813);
ok('the same length both ways is a shortcut', await probeDirect(freshSource()) === true);
// The bug: 642497 is static.eporner.com/na.mp4, the stand-in clip.
stubFetch(642497, 486933813);
ok('a shorter file than the proxy reports is a substitution',
await probeDirect(freshSource()) === false);
// Not every mismatch is a decoy -- a longer one is just as wrong.
stubFetch(486933813, 642497);
ok('any disagreement about length is refused',
await probeDirect(freshSource()) === false);
console.log('\nwhen a length cannot be had');
stubFetch(642497, null);
ok('a proxy that states no length leaves the bytes test standing',
await probeDirect(freshSource()) === true);
stubFetch(null, 486933813);
ok('a direct route that states no length is not accused',
await probeDirect(freshSource()) === true);
stubFetch(null, null);
ok('neither stating one still plays direct',
await probeDirect(freshSource()) === true);
console.log('\nhow the cross-check is addressed');
stubFetch(486933813, 486933813);
const src = freshSource();
await probeDirect(src);
const head = calls.find((c) => c.url.indexOf('/api/stream') === 0);
ok('the proxy is asked, by HEAD', !!head && head.method === 'HEAD',
head ? head.method : 'never asked');
ok('with the Referer the origin wants, not the page\'s',
!!head && head.url.indexOf(`referer=${encodeURIComponent('https://www.eporner.com/')}`) !== -1,
head ? head.url : '');
ok('about the very URL being probed',
!!head && head.url.indexOf(encodeURIComponent(src.url)) !== -1);
console.log('\nthe answer is kept per host');
stubFetch(486933813, 486933813);
const repeat = sourceOn('cached.example.com');
await probeDirect(repeat);
const afterFirst = calls.length;
await probeDirect(repeat);
ok('a second probe of a known host asks nobody', calls.length === afterFirst,
`${calls.length - afterFirst} extra call(s)`);
console.log('\na bare URL still works');
stubFetch(642497, 486933813);
ok('a string is probed, and still cross-checked',
await probeDirect('https://bare.example.com/v.mp4') === false);
stubFetch(486933813, 486933813);
ok('nothing to probe is not playable', await probeDirect('') === false);
ok('neither is a source without a URL', await probeDirect({ url: '' }) === false);
};
run().then(() => {
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
process.exit(failed ? 1 : 0);
}).catch((err) => {
console.error(err);
process.exit(1);
});

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);