Compare commits
22 Commits
b8a51d9ee2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6045a2b1 | ||
|
|
93463bce3a | ||
|
|
2fcc17877d | ||
|
|
c60f5e01dc | ||
|
|
7033288bc4 | ||
|
|
872786a657 | ||
|
|
7fc718158a | ||
|
|
4f772773e1 | ||
|
|
be058dba37 | ||
|
|
6dac1028a3 | ||
|
|
e8eac70f4d | ||
|
|
d5c859a1c7 | ||
|
|
6d9cc301a7 | ||
|
|
b9f6a78fdc | ||
|
|
bc7c0047b7 | ||
|
|
f977852253 | ||
|
|
553d610e5b | ||
|
|
adb254c20b | ||
|
|
8163693150 | ||
|
|
1b38f9d9e7 | ||
|
|
741786c7cf | ||
|
|
39a47289b9 |
@@ -139,7 +139,8 @@ body.theme-light .top-bar {
|
|||||||
|
|
||||||
.search-container input {
|
.search-container input {
|
||||||
width: 100%;
|
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);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -159,10 +160,17 @@ body.theme-light .top-bar {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A merge reads its members' own words, so the box has nothing to say for it.
|
||||||
|
Dimmed rather than hidden: the placeholder is where it says why. */
|
||||||
|
.search-container input.is-inert {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.search-clear-btn {
|
.search-clear-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: 12px;
|
right: 44px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -191,6 +199,53 @@ body.theme-light .top-bar {
|
|||||||
color: var(--text-primary);
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -2188,6 +2243,27 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
height: 1px;
|
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
|
/* Stands in for the slides above the rendered window so scroll position and
|
||||||
snap points stay stable while slides are virtualized in and out. */
|
snap points stay stable while slides are virtualized in and out. */
|
||||||
.feed-top-spacer {
|
.feed-top-spacer {
|
||||||
@@ -2198,12 +2274,17 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
|
|
||||||
/* One screenful. It holds the pane tree rather than a video directly: with
|
/* 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
|
several panes, a step shows that many videos at once and one swipe advances
|
||||||
the whole set. */
|
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 {
|
.feed-slide {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
height: 100dvh;
|
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
scroll-snap-stop: always;
|
scroll-snap-stop: always;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -3014,3 +3095,416 @@ body.theme-light .video-card img:not(.is-loaded) {
|
|||||||
pointer-events: none;
|
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; }
|
||||||
|
|
||||||
|
/* Picking rows to merge. The count becomes a tick box: empty until the row is
|
||||||
|
chosen, so a glance down the list says which ones are going in. */
|
||||||
|
.saved-count.saved-pick {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-row.is-picked .saved-count.saved-pick {
|
||||||
|
background: rgba(201, 165, 103, 0.16);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-row.is-picked { background: var(--bg-tertiary); }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,13 +15,30 @@
|
|||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<div class="logo">Jacuzzi</div>
|
<div class="logo">Jacuzzi</div>
|
||||||
<div class="search-container">
|
<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-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>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
<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">
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
|
||||||
</button>
|
</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">
|
<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">
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
|
||||||
</button>
|
</button>
|
||||||
@@ -146,6 +163,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="sources-list" class="sources-list"></div>
|
<div id="sources-list" class="sources-list"></div>
|
||||||
</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">
|
<div class="sidebar-section">
|
||||||
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
|
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
@@ -226,6 +251,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-select-btn" class="btn-link" type="button">Merge…</button>
|
||||||
|
<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-merge-btn" class="btn-secondary saved-add" type="button" hidden>Merge selected</button>
|
||||||
|
<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/state.js"></script>
|
||||||
<script src="static/js/marquee.js"></script>
|
<script src="static/js/marquee.js"></script>
|
||||||
<script src="static/js/storage.js"></script>
|
<script src="static/js/storage.js"></script>
|
||||||
@@ -233,13 +286,16 @@
|
|||||||
<script src="static/js/player.js"></script>
|
<script src="static/js/player.js"></script>
|
||||||
<script src="static/js/favorites.js"></script>
|
<script src="static/js/favorites.js"></script>
|
||||||
<script src="static/js/favoritesView.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/sqlite.js"></script>
|
||||||
<script src="static/js/hottubBackup.js"></script>
|
<script src="static/js/hottubBackup.js"></script>
|
||||||
<script src="static/js/videos.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/feed.js"></script>
|
||||||
<script src="static/js/ui.js"></script>
|
<script src="static/js/ui.js"></script>
|
||||||
<script src="static/js/version.js"></script>
|
<script src="static/js/version.js"></script>
|
||||||
<script src="static/js/enhance.js"></script>
|
<script src="static/js/enhance.js"></script>
|
||||||
|
<script src="static/js/privacy.js"></script>
|
||||||
<script src="static/js/main.js"></script>
|
<script src="static/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -185,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: '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 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'); } });
|
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ App.favorites = App.favorites || {};
|
|||||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||||
const parsed = raw ? JSON.parse(raw) : [];
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
if (!Array.isArray(parsed)) return [];
|
if (!Array.isArray(parsed)) return [];
|
||||||
// Two things are repaired on the way in, and written back once if
|
// One thing is repaired on the way in, and written back once if
|
||||||
// anything changed, so the fix happens exactly one time:
|
// anything changed, so the fix happens exactly one time:
|
||||||
//
|
//
|
||||||
// `meta`, from older versions, is a blob of resolved formats whose
|
|
||||||
// URLs are signed and long expired -- dropped so no code path can
|
|
||||||
// reach for one; everything re-resolves from `url` at play time.
|
|
||||||
//
|
|
||||||
// `favoriteDate` didn't exist before sorting needed it. There's no
|
// `favoriteDate` didn't exist before sorting needed it. There's no
|
||||||
// way to recover when an old favorite was actually saved, so it
|
// way to recover when an old favorite was actually saved, so it
|
||||||
// gets now: they sort together, as one batch, at the point the
|
// gets now: they sort together, as one batch, at the point the
|
||||||
@@ -31,10 +27,9 @@ App.favorites = App.favorites || {};
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const items = parsed.map((item) => {
|
const items = parsed.map((item) => {
|
||||||
if (!item || typeof item !== 'object') return item;
|
if (!item || typeof item !== 'object') return item;
|
||||||
if (!item.meta && item.favoriteDate) return item;
|
if (item.favoriteDate) return item;
|
||||||
const clean = Object.assign({}, item);
|
const clean = Object.assign({}, item);
|
||||||
delete clean.meta;
|
clean.favoriteDate = now;
|
||||||
if (!clean.favoriteDate) clean.favoriteDate = now;
|
|
||||||
repaired = true;
|
repaired = true;
|
||||||
return clean;
|
return clean;
|
||||||
});
|
});
|
||||||
@@ -103,6 +98,18 @@ App.favorites = App.favorites || {};
|
|||||||
return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
|
return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatsOf = function(video, meta) {
|
||||||
|
const src = Array.isArray(video.formats) && video.formats.length ? video
|
||||||
|
: (meta && Array.isArray(meta.formats) && meta.formats.length ? meta : null);
|
||||||
|
if (!src) return undefined;
|
||||||
|
return {
|
||||||
|
url: src.url || video.url || '',
|
||||||
|
formats: src.formats,
|
||||||
|
http_headers: src.http_headers || {},
|
||||||
|
isLive: !!(src.isLive || video.isLive)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
App.favorites.normalize = function(video) {
|
App.favorites.normalize = function(video) {
|
||||||
const key = App.favorites.getKey(video);
|
const key = App.favorites.getKey(video);
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
@@ -110,9 +117,7 @@ App.favorites = App.favorites || {};
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
id: video.id || null,
|
id: video.id || null,
|
||||||
// The page/source URL (e.g. the YouTube watch URL), not a resolved
|
// The page/source URL (e.g. the YouTube watch URL).
|
||||||
// CDN media URL -- those expire, so favorites must always re-resolve
|
|
||||||
// via the server at play time instead of caching a stream link.
|
|
||||||
url: video.url || (meta && meta.url) || '',
|
url: video.url || (meta && meta.url) || '',
|
||||||
title: video.title || '',
|
title: video.title || '',
|
||||||
thumb: video.thumb || '',
|
thumb: video.thumb || '',
|
||||||
@@ -122,13 +127,10 @@ App.favorites = App.favorites || {};
|
|||||||
isLive: !!(video.isLive || (meta && meta.isLive)),
|
isLive: !!(video.isLive || (meta && meta.isLive)),
|
||||||
// When it was saved. An import carries the date the other client
|
// When it was saved. An import carries the date the other client
|
||||||
// recorded; anything saved here is saved now.
|
// recorded; anything saved here is saved now.
|
||||||
favoriteDate: video.favoriteDate || new Date().toISOString()
|
favoriteDate: video.favoriteDate || new Date().toISOString(),
|
||||||
// No `meta` field: persisting resolved formats would freeze their
|
// The formats the video was saved with, so it plays without being
|
||||||
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
|
// resolved again. Only when there are some.
|
||||||
// favorite look like a fresh, unresolved listing item again, so
|
meta: formatsOf(video, meta)
|
||||||
// playback/download/info all re-resolve through the backend from
|
|
||||||
// `url` -- see resolveStreamSources' no-formats fallback, which the
|
|
||||||
// backend resolves live via yt-dlp (main.py stream_video).
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -251,7 +253,7 @@ App.favorites = App.favorites || {};
|
|||||||
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
||||||
|
|
||||||
const sameEntry = function(a, b) {
|
const sameEntry = function(a, b) {
|
||||||
return ENTRY_FIELDS.every((field) =>
|
return JSON.stringify(a.meta || null) === JSON.stringify(b.meta || null) && ENTRY_FIELDS.every((field) =>
|
||||||
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
|
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
|
||||||
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
||||||
};
|
};
|
||||||
@@ -494,14 +496,11 @@ App.favorites = App.favorites || {};
|
|||||||
`;
|
`;
|
||||||
const thumb = card.querySelector('img');
|
const thumb = card.querySelector('img');
|
||||||
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
||||||
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item));
|
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item), item);
|
||||||
}
|
}
|
||||||
card.onclick = () => {
|
card.onclick = () => {
|
||||||
if (card.classList.contains('is-loading')) return;
|
if (card.classList.contains('is-loading')) return;
|
||||||
card.classList.add('is-loading');
|
card.classList.add('is-loading');
|
||||||
// Ignore any stale `meta` a favorite saved before this fix may
|
|
||||||
// still carry in localStorage -- always re-resolve from `item`
|
|
||||||
// (id/url) so playback never reuses an expired stream URL.
|
|
||||||
App.player.open(item, { originEl: card });
|
App.player.open(item, { originEl: card });
|
||||||
};
|
};
|
||||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||||
@@ -525,9 +524,6 @@ App.favorites = App.favorites || {};
|
|||||||
showInfoBtn.onclick = (event) => {
|
showInfoBtn.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.videos.closeAllMenus();
|
App.videos.closeAllMenus();
|
||||||
// Favorites deliberately store no resolved metadata; the
|
|
||||||
// panel opens on what the entry holds and resolves the rest
|
|
||||||
// itself.
|
|
||||||
App.ui.openInfo(item);
|
App.ui.openInfo(item);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ App.feed = App.feed || {};
|
|||||||
// Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the
|
// Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the
|
||||||
// active slide so the data buffer is always full before we need to render
|
// active slide so the data buffer is always full before we need to render
|
||||||
// a slide from it.
|
// a slide from it.
|
||||||
const PRELOAD_COUNT = 2; // slides ahead kept with a live <video> playing/preloaded
|
// Preloading is counted in steps, so it scales with the split rather than
|
||||||
|
// being shared out between the panels: every panel keeps its own next three
|
||||||
|
// videos ready, which is N * 3 videos loading ahead for a step of N panels.
|
||||||
|
// The step right behind the one being watched buffers for real; the two
|
||||||
|
// behind that hold their metadata, which is what makes their first frame
|
||||||
|
// immediate without four more streams downloading in full.
|
||||||
|
const PRELOAD_STEPS = 3; // steps ahead kept loaded, per panel
|
||||||
|
const BUFFER_STEPS = 1; // of those, how many buffer rather than just resolve
|
||||||
const RENDER_AHEAD = 5; // slides ahead kept materialized in the DOM
|
const RENDER_AHEAD = 5; // slides ahead kept materialized in the DOM
|
||||||
const HISTORY_COUNT = 5; // slides behind kept materialized in the DOM
|
const HISTORY_COUNT = 5; // slides behind kept materialized in the DOM
|
||||||
const PREFETCH_PAGES = 2; // pages of JSON to keep buffered ahead of the active slide
|
const PREFETCH_PAGES = 2; // pages of JSON to keep buffered ahead of the active slide
|
||||||
@@ -37,9 +44,62 @@ App.feed = App.feed || {};
|
|||||||
// scroll events with positions that no longer map to the active slide;
|
// scroll events with positions that no longer map to the active slide;
|
||||||
// onResize sets this for the brief realign window so those events don't
|
// onResize sets this for the brief realign window so those events don't
|
||||||
// flip the active video -- rotating the device must never change which
|
// flip the active video -- rotating the device must never change which
|
||||||
// slide is playing. Normal swipes (no resize in flight) are unaffected.
|
// slide is playing.
|
||||||
|
//
|
||||||
|
// What it must not ignore is the reader. A re-snap has nothing behind it;
|
||||||
|
// a swipe has a finger or a wheel. From the scroll event alone the two are
|
||||||
|
// identical, so the input is recorded and a scroll that follows one closes
|
||||||
|
// the window instead of being reverted into it. Without that, a swipe on a
|
||||||
|
// phone was routinely thrown away: the chrome sliding out of the way *is*
|
||||||
|
// the scroll, so the window was open at exactly the moment it did most
|
||||||
|
// harm -- the reel moved under the finger, showing the next video coming
|
||||||
|
// in, and was then pulled back to where it started.
|
||||||
let suppressScroll = false;
|
let suppressScroll = false;
|
||||||
let resizeSettleRaf = null;
|
let resizeSettleRaf = null;
|
||||||
|
let resizeSettleTimer = null;
|
||||||
|
const INPUT_GRACE_MS = 700;
|
||||||
|
let lastInputAt = 0;
|
||||||
|
// A rotation is not one event. The viewport, the browser's own chrome and
|
||||||
|
// the scroll-snap container settle over many frames and often several
|
||||||
|
// `resize` events, so the realign window is held open until the slide
|
||||||
|
// height has held still for a stretch of frames -- not for a fixed two,
|
||||||
|
// which ran out while the viewport was still moving and let the stale
|
||||||
|
// scroll positions through, skipping the reel forward a few videos.
|
||||||
|
const SETTLE_STABLE_FRAMES = 10;
|
||||||
|
const SETTLE_MAX_MS = 1500;
|
||||||
|
let settleStartedAt = 0;
|
||||||
|
let settleStableFrames = 0;
|
||||||
|
let settleHeight = -1;
|
||||||
|
let settleDone = null; // run once the window closes, if anything asked
|
||||||
|
let lastMeasuredHeight = -1;
|
||||||
|
|
||||||
|
// A turn is not just another resize. 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: that run of hard jumps is
|
||||||
|
// what reads as a stutter. So for the length of the 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, so the
|
||||||
|
// picture moves exactly once, straight into the place it then keeps.
|
||||||
|
// Only a real turn gets that: the browser's own chrome sliding away moves
|
||||||
|
// the viewport too, and holding for those would be worse than the jump it
|
||||||
|
// saves.
|
||||||
|
//
|
||||||
|
// `orientationchange` fires before the new metrics are readable, which is
|
||||||
|
// the earliest anything can know a turn is coming -- early enough to pin
|
||||||
|
// before the first new shape is painted. It is also the one signal that
|
||||||
|
// separates a turn from a window being dragged wider on a desktop.
|
||||||
|
const TURN_SIGNAL_MS = 1200;
|
||||||
|
// The hold comes off once the viewport has stopped moving -- sooner than
|
||||||
|
// the realign window closes, which goes on holding the reel in place for
|
||||||
|
// a while after that in case a last stale scroll arrives. Long enough
|
||||||
|
// that a viewport animating round in steps is not mistaken for one that
|
||||||
|
// has arrived, short enough not to be a wait.
|
||||||
|
const TURN_HOLD_FRAMES = 6;
|
||||||
|
let turnSignalAt = 0;
|
||||||
|
let turnPin = null; // the shape the slides are held at, mid-turn
|
||||||
|
let turnResolved = false;
|
||||||
|
let settleFromShape = null; // the shape the viewport had before this turn
|
||||||
|
let lastShape = null;
|
||||||
|
|
||||||
// HUD auto-hide: the reels HUD fades out after this much inactivity and
|
// HUD auto-hide: the reels HUD fades out after this much inactivity and
|
||||||
// reappears on any pointer movement / tap / scroll. Buttons keep their
|
// reappears on any pointer movement / tap / scroll. Buttons keep their
|
||||||
@@ -92,6 +152,21 @@ App.feed = App.feed || {};
|
|||||||
if (state.feedOpen) scheduleHudHide();
|
if (state.feedOpen) scheduleHudHide();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Moving through the feed is not using the HUD. A swipe to the next reel
|
||||||
|
// used to put the controls up over the video arriving -- the one thing the
|
||||||
|
// swipe was for -- and leave them there for a second. So the scroll puts
|
||||||
|
// them away instead, and only a real interaction (a tap, the mouse moving,
|
||||||
|
// a control being used) brings them back.
|
||||||
|
const sleepHud = function() {
|
||||||
|
if (hudIdleTimer) {
|
||||||
|
clearTimeout(hudIdleTimer);
|
||||||
|
hudIdleTimer = null;
|
||||||
|
}
|
||||||
|
setHudIdle(true);
|
||||||
|
const root = getRoot();
|
||||||
|
if (root) root.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
|
||||||
|
};
|
||||||
|
|
||||||
const inRoot = function(id) {
|
const inRoot = function(id) {
|
||||||
const root = getRoot();
|
const root = getRoot();
|
||||||
return root ? root.querySelector(`#${id}`) : null;
|
return root ? root.querySelector(`#${id}`) : null;
|
||||||
@@ -102,6 +177,10 @@ App.feed = App.feed || {};
|
|||||||
const getTopSpacer = () => inRoot('feed-top-spacer');
|
const getTopSpacer = () => inRoot('feed-top-spacer');
|
||||||
|
|
||||||
const slideHeight = function() {
|
const slideHeight = function() {
|
||||||
|
// Mid-turn the slides are the size the turn is heading for, not the
|
||||||
|
// size the viewport happens to be passing through, and every scroll
|
||||||
|
// position in the feed is a multiple of a slide.
|
||||||
|
if (turnPin) return turnPin.h;
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
const view = feedDocument().defaultView || window;
|
const view = feedDocument().defaultView || window;
|
||||||
return (scroller && scroller.clientHeight) || view.innerHeight || 1;
|
return (scroller && scroller.clientHeight) || view.innerHeight || 1;
|
||||||
@@ -287,6 +366,8 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
timeline.addEventListener('pointerdown', (event) => {
|
timeline.addEventListener('pointerdown', (event) => {
|
||||||
scrubbing = true;
|
scrubbing = true;
|
||||||
|
// Dragging the scrubber is the one drag that *is* the HUD.
|
||||||
|
wakeHud();
|
||||||
timeline.classList.add('is-scrubbing');
|
timeline.classList.add('is-scrubbing');
|
||||||
timeline.setPointerCapture(event.pointerId);
|
timeline.setPointerCapture(event.pointerId);
|
||||||
seekFromPointer(slide, video, timeline, event.clientX);
|
seekFromPointer(slide, video, timeline, event.clientX);
|
||||||
@@ -296,6 +377,7 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
timeline.addEventListener('pointermove', (event) => {
|
timeline.addEventListener('pointermove', (event) => {
|
||||||
if (!scrubbing) return;
|
if (!scrubbing) return;
|
||||||
|
wakeHud();
|
||||||
seekFromPointer(slide, video, timeline, event.clientX);
|
seekFromPointer(slide, video, timeline, event.clientX);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -433,14 +515,20 @@ App.feed = App.feed || {};
|
|||||||
return RENDITION_STEPS.find((step) => step >= needed) || 0;
|
return RENDITION_STEPS.find((step) => step >= needed) || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSlideSource = function(slide, videoData, autoplay) {
|
// `opts.silent` plays without sound whatever the panel's own switch says --
|
||||||
|
// for a panel that is only half on screen (see peek), whose audio would
|
||||||
|
// otherwise talk over the one still being watched. `opts.buffer` asks for a
|
||||||
|
// real buffer rather than metadata alone, for a step close enough that it
|
||||||
|
// is about to be swiped to.
|
||||||
|
const loadSlideSource = function(slide, videoData, autoplay, opts) {
|
||||||
const video = slide.querySelector('.feed-video');
|
const video = slide.querySelector('.feed-video');
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
const silent = !!(opts && opts.silent);
|
||||||
if (slide.classList.contains('is-loaded')) {
|
if (slide.classList.contains('is-loaded')) {
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
// Picks up a stream that was parked after preloading.
|
// Picks up a stream that was parked after preloading.
|
||||||
if (video._hlsPlayer) video._hlsPlayer.startLoad();
|
if (video._hlsPlayer) video._hlsPlayer.startLoad();
|
||||||
video.muted = slide._muted !== false;
|
video.muted = silent || slide._muted !== false;
|
||||||
const playPromise = video.play();
|
const playPromise = video.play();
|
||||||
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -483,12 +571,12 @@ App.feed = App.feed || {};
|
|||||||
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||||
const isHls = App.videos.classifySource(resolved).isHls;
|
const isHls = App.videos.classifySource(resolved).isHls;
|
||||||
|
|
||||||
video.muted = slide._muted !== false;
|
video.muted = silent || slide._muted !== false;
|
||||||
// The step being watched buffers properly; the one preloaded behind it
|
// The step being watched and the one right behind it buffer properly,
|
||||||
// only needs enough to start instantly on the swipe. With four panes
|
// so a swipe lands on video that is already there. Steps further ahead
|
||||||
// that is the difference between four extra streams downloading and
|
// take their metadata only: enough to start without delay, without four
|
||||||
// four holding a few seconds each.
|
// more streams downloading in full behind them.
|
||||||
video.preload = autoplay ? 'auto' : 'metadata';
|
video.preload = (autoplay || (opts && opts.buffer)) ? 'auto' : 'metadata';
|
||||||
video._tearingDown = false;
|
video._tearingDown = false;
|
||||||
applyResume(video, videoData && videoData.id, resolved.isLive);
|
applyResume(video, videoData && videoData.id, resolved.isLive);
|
||||||
|
|
||||||
@@ -512,11 +600,13 @@ App.feed = App.feed || {};
|
|||||||
video._hlsPlayer = hls;
|
video._hlsPlayer = hls;
|
||||||
hls.loadSource(streamUrl);
|
hls.loadSource(streamUrl);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
if (!autoplay && split) {
|
if (!autoplay && split && !(opts && opts.buffer)) {
|
||||||
// Buffered enough to start instantly, then stopped: four
|
// Buffered enough to start instantly, then stopped: four
|
||||||
// panels' worth of hls.js all fetching and demuxing ahead is
|
// panels' worth of hls.js all fetching and demuxing ahead is
|
||||||
// work for a step the reader has not swiped to yet. loadSlide-
|
// work for a step the reader has not swiped to yet. The step
|
||||||
// Source runs again with autoplay when they do.
|
// right behind the active one is exempt -- it is the one about
|
||||||
|
// to be swiped to, and maxBufferLength stops it on its own.
|
||||||
|
// loadSlideSource runs again with autoplay when they do.
|
||||||
hls.on(HlsLib.Events.FRAG_BUFFERED, function once() {
|
hls.on(HlsLib.Events.FRAG_BUFFERED, function once() {
|
||||||
hls.off(HlsLib.Events.FRAG_BUFFERED, once);
|
hls.off(HlsLib.Events.FRAG_BUFFERED, once);
|
||||||
if (video._hlsPlayer === hls) hls.stopLoad();
|
if (video._hlsPlayer === hls) hls.stopLoad();
|
||||||
@@ -620,7 +710,7 @@ App.feed = App.feed || {};
|
|||||||
// around whatever video is playing so the reader keeps their place.
|
// around whatever video is playing so the reader keeps their place.
|
||||||
const rebuildLayout = function() {
|
const rebuildLayout = function() {
|
||||||
const keepId = state.feedActiveVideoId;
|
const keepId = state.feedActiveVideoId;
|
||||||
teardownAllSlides();
|
reserveSlides();
|
||||||
const videos = state.loadedVideos || [];
|
const videos = state.loadedVideos || [];
|
||||||
let videoIndex = 0;
|
let videoIndex = 0;
|
||||||
if (keepId != null) {
|
if (keepId != null) {
|
||||||
@@ -630,22 +720,66 @@ App.feed = App.feed || {};
|
|||||||
state.feedActiveIndex = -1;
|
state.feedActiveIndex = -1;
|
||||||
const step = Math.floor(videoIndex / paneCount());
|
const step = Math.floor(videoIndex / paneCount());
|
||||||
setActive(step);
|
setActive(step);
|
||||||
|
releaseReserve();
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
if (scroller) scroller.scrollTop = step * slideHeight();
|
if (scroller) scroller.scrollTop = step * slideHeight();
|
||||||
};
|
};
|
||||||
|
|
||||||
const teardownAllSlides = function() {
|
// ------------------------------------------------------------------
|
||||||
|
// Reusing panes across a layout change
|
||||||
|
//
|
||||||
|
// Splitting or closing a panel changes the stride, so every step is
|
||||||
|
// renumbered and the slides have to be built again -- but the videos
|
||||||
|
// haven't changed. A pane already holds one: its <video> with the bytes it
|
||||||
|
// has buffered, where it had got to, which quality it was told to play.
|
||||||
|
// Building a fresh one throws all of that away and fetches it again, so
|
||||||
|
// the panes are set aside by video id first and whoever needs that video
|
||||||
|
// next takes the pane that already has it.
|
||||||
|
//
|
||||||
|
// Everything happens in one go, without yielding, so a pane never stops
|
||||||
|
// being in the document long enough to be paused for it.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const paneReserve = new Map();
|
||||||
|
|
||||||
|
const reserveSlides = function() {
|
||||||
slidesByIndex.forEach((slide) => {
|
slidesByIndex.forEach((slide) => {
|
||||||
teardownSlide(slide);
|
panesOf(slide).forEach((pane) => {
|
||||||
|
const id = slideVideoId(pane);
|
||||||
|
const key = id == null ? null : String(id);
|
||||||
|
if (key !== null && !paneReserve.has(key)) {
|
||||||
|
paneReserve.set(key, pane);
|
||||||
|
if (pane.parentNode) pane.parentNode.removeChild(pane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teardownPane(pane); // a second pane on the same video
|
||||||
|
});
|
||||||
|
slide._panes = [];
|
||||||
slide.remove();
|
slide.remove();
|
||||||
});
|
});
|
||||||
slidesByIndex.clear();
|
slidesByIndex.clear();
|
||||||
|
forgetPeeks();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Whatever the new layout had no place for -- it is holding a stream open.
|
||||||
|
const releaseReserve = function() {
|
||||||
|
paneReserve.forEach(teardownPane);
|
||||||
|
paneReserve.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Everything one video needs on screen. Identical to what a slide used to
|
// Everything one video needs on screen. Identical to what a slide used to
|
||||||
// hold: the per-video helpers below take any element shaped like this, so a
|
// hold: the per-video helpers below take any element shaped like this, so a
|
||||||
// pane and the old single-video slide are interchangeable to them.
|
// pane and the old single-video slide are interchangeable to them.
|
||||||
const buildPane = function(v, index) {
|
const buildPane = function(v, index) {
|
||||||
|
const held = v && v.id != null ? paneReserve.get(String(v.id)) : null;
|
||||||
|
if (held) {
|
||||||
|
paneReserve.delete(String(v.id));
|
||||||
|
// The stride changed, so this video sits at a different place in
|
||||||
|
// the queue than it did; nothing else about the pane has moved.
|
||||||
|
held.dataset.index = String(index);
|
||||||
|
held._index = index;
|
||||||
|
held._videoData = v;
|
||||||
|
return held;
|
||||||
|
}
|
||||||
const pane = document.createElement('div');
|
const pane = document.createElement('div');
|
||||||
pane.className = v.isLive ? 'feed-pane is-live' : 'feed-pane';
|
pane.className = v.isLive ? 'feed-pane is-live' : 'feed-pane';
|
||||||
pane.dataset.videoId = v.id;
|
pane.dataset.videoId = v.id;
|
||||||
@@ -667,7 +801,7 @@ App.feed = App.feed || {};
|
|||||||
<div class="cp-format-menu feed-format-menu" role="menu" hidden></div>
|
<div class="cp-format-menu feed-format-menu" role="menu" hidden></div>
|
||||||
<div class="feed-pane-tools">
|
<div class="feed-pane-tools">
|
||||||
<button class="feed-pane-btn feed-pane-mute" type="button" aria-label="Mute this panel"></button>
|
<button class="feed-pane-btn feed-pane-mute" type="button" aria-label="Mute this panel"></button>
|
||||||
<button class="feed-pane-btn feed-pane-split-right" type="button" title="Add a panel to the right" aria-label="Add a panel to the right">⊞</button>
|
<button class="feed-pane-btn feed-pane-split-right" type="button" title="Add a panel to the right" aria-label="Add a panel to the right">◫</button>
|
||||||
<button class="feed-pane-btn feed-pane-split-down" type="button" title="Add a panel below" aria-label="Add a panel below">⊟</button>
|
<button class="feed-pane-btn feed-pane-split-down" type="button" title="Add a panel below" aria-label="Add a panel below">⊟</button>
|
||||||
<button class="feed-pane-btn feed-pane-close" type="button" title="Close this panel" aria-label="Close this panel" hidden>✕</button>
|
<button class="feed-pane-btn feed-pane-close" type="button" title="Close this panel" aria-label="Close this panel" hidden>✕</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -684,7 +818,7 @@ App.feed = App.feed || {};
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const poster = pane.querySelector('.feed-poster');
|
const poster = pane.querySelector('.feed-poster');
|
||||||
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
|
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||||
const slideVideo = pane.querySelector('.feed-video');
|
const slideVideo = pane.querySelector('.feed-video');
|
||||||
bindTimeline(pane, slideVideo);
|
bindTimeline(pane, slideVideo);
|
||||||
bindSharedControls(pane, slideVideo, v);
|
bindSharedControls(pane, slideVideo, v);
|
||||||
@@ -780,21 +914,36 @@ App.feed = App.feed || {};
|
|||||||
return split.childElementCount ? split : null;
|
return split.childElementCount ? split : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rebindable, because a pane outlives the tree it was built for: a reused
|
||||||
|
// pane comes back under a different node, and its old buttons would still
|
||||||
|
// be splitting and closing the one it left.
|
||||||
const bindPaneTools = function(pane, node) {
|
const bindPaneTools = function(pane, node) {
|
||||||
|
if (Array.isArray(pane._toolCleanups)) {
|
||||||
|
pane._toolCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||||
|
}
|
||||||
|
const cleanups = [];
|
||||||
|
const on = function(selector, handler) {
|
||||||
|
const el = pane.querySelector(selector);
|
||||||
|
if (!el) return el;
|
||||||
|
el.addEventListener('click', handler);
|
||||||
|
cleanups.push(() => el.removeEventListener('click', handler));
|
||||||
|
return el;
|
||||||
|
};
|
||||||
const closeBtn = pane.querySelector('.feed-pane-close');
|
const closeBtn = pane.querySelector('.feed-pane-close');
|
||||||
closeBtn.hidden = paneCount() <= 1;
|
closeBtn.hidden = paneCount() <= 1;
|
||||||
pane.querySelector('.feed-pane-split-right').addEventListener('click', (event) => {
|
on('.feed-pane-split-right', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
splitPane(node, 'row');
|
splitPane(node, 'row');
|
||||||
});
|
});
|
||||||
pane.querySelector('.feed-pane-split-down').addEventListener('click', (event) => {
|
on('.feed-pane-split-down', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
splitPane(node, 'col');
|
splitPane(node, 'col');
|
||||||
});
|
});
|
||||||
closeBtn.addEventListener('click', (event) => {
|
on('.feed-pane-close', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
closePane(node);
|
closePane(node);
|
||||||
});
|
});
|
||||||
|
pane._toolCleanups = cleanups;
|
||||||
};
|
};
|
||||||
|
|
||||||
// One screenful: the pane tree, filled with this step's run of videos.
|
// One screenful: the pane tree, filled with this step's run of videos.
|
||||||
@@ -932,10 +1081,12 @@ App.feed = App.feed || {};
|
|||||||
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
||||||
// any missing ones inside it, and sizes the top spacer to stand in for the
|
// any missing ones inside it, and sizes the top spacer to stand in for the
|
||||||
// slides above the window.
|
// slides above the window.
|
||||||
// Which steps are materialised. Steps ahead/behind are counted in steps,
|
// Which steps are materialised. Behind, the window narrows as panes are
|
||||||
// but each one now costs a whole step's worth of <video> elements, so
|
// added -- history is the cheap thing to give up. Ahead it does not: what
|
||||||
// splitting four ways should not put four times as many in the document:
|
// is preloaded has to be in the document to be preloaded at all, so the
|
||||||
// the window narrows as panes are added.
|
// window always reaches at least PRELOAD_STEPS forward however many panels
|
||||||
|
// there are. A four-way split therefore holds four times as many elements
|
||||||
|
// as a single panel, which is the price of four panels never waiting.
|
||||||
//
|
//
|
||||||
// The top spacer stands in for the steps below `start`, so anything that
|
// The top spacer stands in for the steps below `start`, so anything that
|
||||||
// sizes the spacer has to agree with this exactly -- disagreeing by a step
|
// sizes the spacer has to agree with this exactly -- disagreeing by a step
|
||||||
@@ -949,7 +1100,7 @@ App.feed = App.feed || {};
|
|||||||
return {
|
return {
|
||||||
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
|
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
|
||||||
end: Math.min(stepCount() - 1,
|
end: Math.min(stepCount() - 1,
|
||||||
activeIndex + Math.max(1, Math.round(RENDER_AHEAD / per)))
|
activeIndex + Math.max(PRELOAD_STEPS, Math.round(RENDER_AHEAD / per)))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -979,13 +1130,38 @@ App.feed = App.feed || {};
|
|||||||
if (spacer) spacer.style.height = `${start * slideHeight()}px`;
|
if (spacer) spacer.style.height = `${start * slideHeight()}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Once the active slide gets within PREFETCH_PAGES of the end of the loaded
|
// The videos the next few swipes will want, resolved now and all at once.
|
||||||
// JSON, pull the next page so the buffer stays ahead of the rendered window.
|
// Resolving is a round trip per video (yt-dlp, on the backend), and leaving
|
||||||
|
// it until load time put that round trip between a panel and its first
|
||||||
|
// frame -- with four panels, four of them, arriving one after another. They
|
||||||
|
// are deduped per video, so asking early costs nothing but the request, and
|
||||||
|
// asking for all of them at once is the point: they run in parallel.
|
||||||
|
const warmFormats = function(activeIndex) {
|
||||||
|
if (!App.videos || typeof App.videos.ensureFormats !== 'function') return;
|
||||||
|
const videos = state.loadedVideos || [];
|
||||||
|
const per = paneCount();
|
||||||
|
const from = Math.max(0, activeIndex * per);
|
||||||
|
const to = Math.min(videos.length, from + per * (PRELOAD_STEPS + 1));
|
||||||
|
for (let i = from; i < to; i++) {
|
||||||
|
if (videos[i]) App.videos.ensureFormats(videos[i]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keeps the JSON buffer ahead of what the feed is about to eat. A step of N
|
||||||
|
// panels consumes N videos per swipe, so the same page of listing goes N
|
||||||
|
// times as fast -- the buffer it needs, and the number of pages worth having
|
||||||
|
// on the wire at once, both scale with the split.
|
||||||
const prefetchIfNeeded = function(activeIndex) {
|
const prefetchIfNeeded = function(activeIndex) {
|
||||||
|
const per = paneCount();
|
||||||
|
const perPage = state.perPage || 12;
|
||||||
const total = (state.loadedVideos || []).length;
|
const total = (state.loadedVideos || []).length;
|
||||||
const bufferAhead = total - 1 - ((activeIndex + 1) * paneCount() - 1);
|
const bufferAhead = total - 1 - ((activeIndex + 1) * per - 1);
|
||||||
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
|
// Everything the rendered window will reach into, plus room to grow.
|
||||||
&& state.hasNextPage && !state.isLoading) {
|
const needed = Math.max(PREFETCH_PAGES * perPage, per * (PRELOAD_STEPS + 2));
|
||||||
|
if (App.videos.setPrefetchDepth) {
|
||||||
|
App.videos.setPrefetchDepth(Math.ceil(needed / perPage));
|
||||||
|
}
|
||||||
|
if (bufferAhead < needed && state.hasNextPage && !state.isLoading) {
|
||||||
// The feed is its own reader: the grid's scroll position says
|
// The feed is its own reader: the grid's scroll position says
|
||||||
// nothing about whether it needs the next page.
|
// nothing about whether it needs the next page.
|
||||||
App.videos.loadVideos({ force: true });
|
App.videos.loadVideos({ force: true });
|
||||||
@@ -993,7 +1169,7 @@ App.feed = App.feed || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Promotes the slide at `index` to active: syncs the window, updates the
|
// Promotes the slide at `index` to active: syncs the window, updates the
|
||||||
// active styling, plays it, preloads the next PRELOAD_COUNT, and tears down
|
// active styling, plays it, preloads the next PRELOAD_STEPS, and tears down
|
||||||
// playback for everything else in the window.
|
// playback for everything else in the window.
|
||||||
const setActive = function(index) {
|
const setActive = function(index) {
|
||||||
const clamped = clampIndex(index);
|
const clamped = clampIndex(index);
|
||||||
@@ -1021,14 +1197,14 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
slidesByIndex.forEach((slide, i) => {
|
slidesByIndex.forEach((slide, i) => {
|
||||||
if (i === clamped) return;
|
if (i === clamped) return;
|
||||||
// Same floor, same reason: at least the next step is preloaded, so
|
const ahead = i - clamped;
|
||||||
// every panel has its next video ready before the swipe. It is
|
|
||||||
// preloaded for *all* of that step's panes, which is what makes the
|
|
||||||
// guarantee hold per panel rather than only for the first.
|
|
||||||
const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
|
|
||||||
panesOf(slide).forEach((pane) => {
|
panesOf(slide).forEach((pane) => {
|
||||||
if (i > clamped && i <= clamped + preloadAhead) {
|
if (ahead > 0 && ahead <= PRELOAD_STEPS) {
|
||||||
loadSlideSource(pane, pane._videoData, false);
|
// Every panel of the step, started together: what used to
|
||||||
|
// make them start one after another is that only the first
|
||||||
|
// step ahead was loaded, so each panel did its resolving
|
||||||
|
// and buffering at the moment it was swiped to.
|
||||||
|
loadSlideSource(pane, pane._videoData, false, { buffer: ahead <= BUFFER_STEPS });
|
||||||
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
||||||
// Recently-watched panes stay loaded but paused so scrolling
|
// Recently-watched panes stay loaded but paused so scrolling
|
||||||
// back resumes seamlessly from where it was paused.
|
// back resumes seamlessly from where it was paused.
|
||||||
@@ -1040,11 +1216,129 @@ App.feed = App.feed || {};
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateAutoPiPTarget();
|
updateAutoPiPTarget();
|
||||||
|
warmFormats(clamped);
|
||||||
prefetchIfNeeded(clamped);
|
prefetchIfNeeded(clamped);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Peeking
|
||||||
|
//
|
||||||
|
// A step used to start playing when the swipe was already half done --
|
||||||
|
// `Math.round` of the scroll position -- and then still had to load. So
|
||||||
|
// the video slid into view as a still picture and began some time after
|
||||||
|
// it arrived, which is most of the waiting in a reel.
|
||||||
|
//
|
||||||
|
// Now the step coming into view starts as soon as any of it is showing,
|
||||||
|
// silently, while the one being left plays on. A swipe that is carried
|
||||||
|
// through therefore lands on a video that is already running; one that is
|
||||||
|
// abandoned leaves nothing behind -- the step that was peeked at is
|
||||||
|
// stopped and wound back, so the next swipe to it starts it from the top
|
||||||
|
// rather than from the second and a half nobody watched.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const PEEK_MIN_PX = 8; // less than this is a twitch, not a swipe
|
||||||
|
const PEEK_SETTLE_MS = 140; // quiet for this long means the scroll is over
|
||||||
|
const peeked = new Set(); // step elements started by a peek
|
||||||
|
let peekSettleTimer = null;
|
||||||
|
|
||||||
|
const startPeek = function(index) {
|
||||||
|
const slide = slidesByIndex.get(index);
|
||||||
|
if (!slide || peeked.has(slide)) return;
|
||||||
|
peeked.add(slide);
|
||||||
|
panesOf(slide).forEach((pane) => {
|
||||||
|
const video = pane.querySelector('.feed-video');
|
||||||
|
const id = slideVideoId(pane);
|
||||||
|
// Where this panel stood before anyone looked at it, so an
|
||||||
|
// abandoned swipe can put it back exactly there: the start for a
|
||||||
|
// step never watched, and where it was left for one scrolled past.
|
||||||
|
pane._peekFrom = (video && video.currentTime) ||
|
||||||
|
(id != null && resumeTimes.get(id)) || 0;
|
||||||
|
loadSlideSource(pane, pane._videoData, true, { silent: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stops a panel and puts it back where the peek found it -- the opposite
|
||||||
|
// of pauseSlide, which keeps where it got to on purpose.
|
||||||
|
const rewindPane = function(pane) {
|
||||||
|
const video = pane.querySelector('.feed-video');
|
||||||
|
const back = pane._peekFrom || 0;
|
||||||
|
pane.classList.remove('is-active');
|
||||||
|
if (video) {
|
||||||
|
if (!video.paused) video.pause();
|
||||||
|
try { video.currentTime = back; } catch (err) { /* not seekable yet */ }
|
||||||
|
}
|
||||||
|
const id = slideVideoId(pane);
|
||||||
|
if (id != null) {
|
||||||
|
if (back > 0) resumeTimes.set(id, back);
|
||||||
|
else resumeTimes.delete(id);
|
||||||
|
}
|
||||||
|
pane._peekFrom = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The scroll has stopped somewhere: whatever was peeked at and not arrived
|
||||||
|
// at goes back to where it started.
|
||||||
|
//
|
||||||
|
// "Stopped" has to mean stopped *on* a step. Scroll events also stop while
|
||||||
|
// a finger holds the list still between two of them -- the swipe is then
|
||||||
|
// neither finished nor abandoned, and winding the incoming step back under
|
||||||
|
// a held finger is precisely the stall this is here to remove.
|
||||||
|
const settlePeek = function() {
|
||||||
|
peekSettleTimer = null;
|
||||||
|
if (!peeked.size) return;
|
||||||
|
const scroller = getScroller();
|
||||||
|
const height = slideHeight();
|
||||||
|
if (scroller && height) {
|
||||||
|
const offset = scroller.scrollTop % height;
|
||||||
|
if (offset > PEEK_MIN_PX && offset < height - PEEK_MIN_PX) return;
|
||||||
|
}
|
||||||
|
const active = slidesByIndex.get(state.feedActiveIndex);
|
||||||
|
peeked.forEach((slide) => {
|
||||||
|
if (slide === active) return;
|
||||||
|
panesOf(slide).forEach(rewindPane);
|
||||||
|
});
|
||||||
|
peeked.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nothing to put back once the steps themselves are gone.
|
||||||
|
const forgetPeeks = function() {
|
||||||
|
if (peekSettleTimer) {
|
||||||
|
clearTimeout(peekSettleTimer);
|
||||||
|
peekSettleTimer = null;
|
||||||
|
}
|
||||||
|
peeked.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed._settlePeek = settlePeek; // tests drive the settle directly
|
||||||
|
|
||||||
|
const peekIncoming = function(scroller) {
|
||||||
|
const height = slideHeight();
|
||||||
|
if (!height) return;
|
||||||
|
const first = Math.floor(scroller.scrollTop / height);
|
||||||
|
const offset = scroller.scrollTop - first * height;
|
||||||
|
if (offset < PEEK_MIN_PX) return; // nothing else is showing yet
|
||||||
|
// Two steps are on screen; the one that isn't being watched is the one
|
||||||
|
// arriving, whichever direction the swipe is going.
|
||||||
|
[first, first + 1].forEach((index) => {
|
||||||
|
if (index === state.feedActiveIndex) return;
|
||||||
|
if (index < 0 || index >= stepCount()) return;
|
||||||
|
startPeek(index);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onScroll = function() {
|
const onScroll = function() {
|
||||||
wakeHud();
|
// A turn's re-anchor arrives as a scroll event carrying a position
|
||||||
|
// measured against the height from before it. Putting it back here,
|
||||||
|
// in the event, is what keeps that position from being painted --
|
||||||
|
// waiting for the next frame means showing it first.
|
||||||
|
if (suppressScroll) {
|
||||||
|
// Unless it is the reader scrolling. A swipe closes the window
|
||||||
|
// instead of being reverted into it -- and closes it without a
|
||||||
|
// realign, or the close would undo the swipe that caused it.
|
||||||
|
if (Date.now() - lastInputAt >= INPUT_GRACE_MS) {
|
||||||
|
realignToActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeSettle(false);
|
||||||
|
}
|
||||||
if (scrollRaf) return;
|
if (scrollRaf) return;
|
||||||
scrollRaf = requestAnimationFrame(() => {
|
scrollRaf = requestAnimationFrame(() => {
|
||||||
scrollRaf = null;
|
scrollRaf = null;
|
||||||
@@ -1053,6 +1347,10 @@ App.feed = App.feed || {};
|
|||||||
// Ignore scroll events fired by a resize/orientation re-snap; the
|
// Ignore scroll events fired by a resize/orientation re-snap; the
|
||||||
// active video is realigned by onResize instead (see suppressScroll).
|
// active video is realigned by onResize instead (see suppressScroll).
|
||||||
if (suppressScroll) return;
|
if (suppressScroll) return;
|
||||||
|
sleepHud();
|
||||||
|
peekIncoming(scroller);
|
||||||
|
if (peekSettleTimer) clearTimeout(peekSettleTimer);
|
||||||
|
peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS);
|
||||||
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
if (index !== state.feedActiveIndex) {
|
if (index !== state.feedActiveIndex) {
|
||||||
@@ -1061,29 +1359,204 @@ App.feed = App.feed || {};
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const viewportShape = function() {
|
||||||
|
const scroller = getScroller();
|
||||||
|
const view = feedDocument().defaultView || window;
|
||||||
|
return {
|
||||||
|
w: (scroller && scroller.clientWidth) || view.innerWidth || 1,
|
||||||
|
h: (scroller && scroller.clientHeight) || view.innerHeight || 1
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// What a wrist did, as against what the browser's chrome did: the device
|
||||||
|
// said so, or the viewport swapped its long side for its short one. A
|
||||||
|
// viewport that only changes height -- chrome sliding away, a keyboard
|
||||||
|
// coming up -- is not a turn and is not worth a fade.
|
||||||
|
const isTurn = function(before, now) {
|
||||||
|
if (Date.now() - turnSignalAt < TURN_SIGNAL_MS) return true;
|
||||||
|
if (!before) return false;
|
||||||
|
// Swapping the long side moves both of them. Reading the flip on its
|
||||||
|
// own calls a few pixels of height a rotation whenever the viewport is
|
||||||
|
// near enough to square for them to cross it -- and then pins the
|
||||||
|
// slides and turns snapping off for a second over the chrome sliding
|
||||||
|
// away, which is the one thing this is here to tell a turn apart from.
|
||||||
|
if (before.w === now.w) return false;
|
||||||
|
return (before.w > before.h) !== (now.w > now.h);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Holds the slides at `shape` -- the size they have as the turn starts --
|
||||||
|
// until the viewport has finished moving. Where a turn arrives in one
|
||||||
|
// step, which is most of them, this lasts a few frames and changes
|
||||||
|
// nothing anybody sees.
|
||||||
|
const pinTurn = function(shape) {
|
||||||
|
turnPin = { w: shape.w, h: shape.h };
|
||||||
|
const root = getRoot();
|
||||||
|
if (!root) return;
|
||||||
|
root.style.setProperty('--turn-w', `${turnPin.w}px`);
|
||||||
|
root.style.setProperty('--turn-h', `${turnPin.h}px`);
|
||||||
|
root.classList.add('is-turning');
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseTurn = function() {
|
||||||
|
if (!turnPin) return;
|
||||||
|
turnPin = null;
|
||||||
|
const root = getRoot();
|
||||||
|
if (!root) return;
|
||||||
|
root.classList.remove('is-turning');
|
||||||
|
root.style.removeProperty('--turn-w');
|
||||||
|
root.style.removeProperty('--turn-h');
|
||||||
|
};
|
||||||
|
|
||||||
// Re-anchors the scroll position on the currently active video after the
|
// Re-anchors the scroll position on the currently active video after the
|
||||||
// viewport changes. The active slide is resolved by id (not by a possibly
|
// viewport changes. The active slide is resolved by id (not by a possibly
|
||||||
// stale scroll position) so an orientation change always keeps the same
|
// stale scroll position) so an orientation change always keeps the same
|
||||||
// video playing/focused rather than snapping to a neighbour.
|
// video playing/focused rather than snapping to a neighbour.
|
||||||
const realignToActive = function() {
|
const realignToActive = function() {
|
||||||
if (stepCount() === 0) return;
|
if (stepCount() === 0) return;
|
||||||
|
const h = slideHeight();
|
||||||
|
const scroller = getScroller();
|
||||||
|
// Anchoring by id is what keeps a rotation on the same video, because
|
||||||
|
// the scroll position it would otherwise read has been invalidated by
|
||||||
|
// the very change being answered. But mid-swipe that id names the video
|
||||||
|
// being *left*: setActive runs from onScroll's next frame, so a resize
|
||||||
|
// landing before it still reads the old one -- and writing its position
|
||||||
|
// hauls the reel back to it. A phone slides its chrome on every scroll,
|
||||||
|
// so that happened on nearly every swipe.
|
||||||
|
//
|
||||||
|
// While the reader is demonstrably driving, the reel's own position is
|
||||||
|
// the better authority: re-anchor on the step it is nearest at the new
|
||||||
|
// height. That still puts it on a snap point -- no slide left straddling
|
||||||
|
// two screens -- without undoing the swipe.
|
||||||
let index = state.feedActiveIndex;
|
let index = state.feedActiveIndex;
|
||||||
if (state.feedActiveVideoId != null) {
|
if (state.feedActiveVideoId != null) {
|
||||||
const found = (state.loadedVideos || [])
|
const found = (state.loadedVideos || [])
|
||||||
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
|
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
|
||||||
if (found >= 0) index = Math.floor(found / paneCount());
|
if (found >= 0) index = Math.floor(found / paneCount());
|
||||||
}
|
}
|
||||||
|
const driving = Date.now() - lastInputAt < INPUT_GRACE_MS;
|
||||||
|
if (driving && scroller && h) {
|
||||||
|
// A swipe moves by a step, so only a step's worth of disagreement
|
||||||
|
// is the reader's doing. Anything further apart is the position
|
||||||
|
// being meaningless rather than ahead -- a scroller that has just
|
||||||
|
// been re-parented into (or out of) the picture-in-picture window
|
||||||
|
// reads 0, and following that would land the reel on the first
|
||||||
|
// video instead of theirs.
|
||||||
|
const atScroll = clampIndex(Math.round(scroller.scrollTop / h));
|
||||||
|
if (atScroll >= 0 && Math.abs(atScroll - index) <= 1) index = atScroll;
|
||||||
|
}
|
||||||
index = clampIndex(index);
|
index = clampIndex(index);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
state.feedActiveIndex = index;
|
state.feedActiveIndex = index;
|
||||||
const h = slideHeight();
|
|
||||||
const start = windowBounds(index).start;
|
const start = windowBounds(index).start;
|
||||||
|
// Only write what actually differs. This runs on every frame of a
|
||||||
|
// turn, and a spacer height or a scrollTop written back unchanged
|
||||||
|
// still costs a layout and still sets the snap container off again --
|
||||||
|
// the page fighting itself at the moment it is busiest.
|
||||||
const spacer = getTopSpacer();
|
const spacer = getTopSpacer();
|
||||||
if (spacer) spacer.style.height = `${start * h}px`;
|
const spacerHeight = `${start * h}px`;
|
||||||
const scroller = getScroller();
|
if (spacer && spacer.style.height !== spacerHeight) spacer.style.height = spacerHeight;
|
||||||
if (scroller) scroller.scrollTop = index * h;
|
const top = index * h;
|
||||||
|
if (scroller && Math.abs(scroller.scrollTop - top) > 0.5) scroller.scrollTop = top;
|
||||||
const activeSlide = slidesByIndex.get(index);
|
const activeSlide = slidesByIndex.get(index);
|
||||||
if (activeSlide) measureFeedTitle(activeSlide);
|
// The title only needs re-measuring when the screen it has to fit in
|
||||||
|
// changed size, not sixty times while it doesn't.
|
||||||
|
if (activeSlide && h !== lastMeasuredHeight) {
|
||||||
|
lastMeasuredHeight = h;
|
||||||
|
measureFeedTitle(activeSlide);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ends the realign window, and `realign` says whether to re-anchor on the
|
||||||
|
// way out. A window that closes on its own does -- holding the reader's
|
||||||
|
// video in place is what it was opened for. One cut short by a swipe must
|
||||||
|
// not, or closing it would undo the very swipe that closed it.
|
||||||
|
const closeSettle = function(realign) {
|
||||||
|
if (resizeSettleRaf) {
|
||||||
|
cancelAnimationFrame(resizeSettleRaf);
|
||||||
|
resizeSettleRaf = null;
|
||||||
|
}
|
||||||
|
if (resizeSettleTimer) {
|
||||||
|
clearTimeout(resizeSettleTimer);
|
||||||
|
resizeSettleTimer = null;
|
||||||
|
}
|
||||||
|
releaseTurn();
|
||||||
|
if (realign) realignToActive();
|
||||||
|
suppressScroll = false;
|
||||||
|
lastShape = viewportShape();
|
||||||
|
const after = settleDone;
|
||||||
|
settleDone = null;
|
||||||
|
if (after) after();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ends the realign window: one last realign, then real swipes count again.
|
||||||
|
const finishSettle = function() {
|
||||||
|
closeSettle(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watches the viewport settle, frame by frame, holding the active video in
|
||||||
|
// place the whole way.
|
||||||
|
const settleStep = function() {
|
||||||
|
resizeSettleRaf = null;
|
||||||
|
if (!state.feedOpen || state.feedActiveIndex < 0) {
|
||||||
|
suppressScroll = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The viewport's own height, not the pinned one: the pin is a fixed
|
||||||
|
// number and would look settled from the first frame.
|
||||||
|
const shape = viewportShape();
|
||||||
|
if (shape.h === settleHeight) settleStableFrames += 1;
|
||||||
|
else {
|
||||||
|
settleHeight = shape.h;
|
||||||
|
settleStableFrames = 0;
|
||||||
|
}
|
||||||
|
// A turn arrives in several steps, and the first of them can be small
|
||||||
|
// enough to pass for the chrome sliding; it is the shape it ends up in
|
||||||
|
// that says what it was.
|
||||||
|
if (!turnPin && !turnResolved && isTurn(settleFromShape, shape)) {
|
||||||
|
pinTurn(settleFromShape);
|
||||||
|
}
|
||||||
|
// A turn the device announced is not over because the viewport paused.
|
||||||
|
// It arrives in steps, and the gap between two of them is routinely
|
||||||
|
// longer than the handful of frames that stands in for "stopped" --
|
||||||
|
// which is how the pin came off mid-turn and let the picture take an
|
||||||
|
// intermediate shape, the one thing it is here to prevent. So while
|
||||||
|
// the announcement stands the pin holds, and the frame count is left
|
||||||
|
// to end the pins nothing announced: a window dragged across the
|
||||||
|
// square, where there is no signal to go on and no turn to ruin.
|
||||||
|
const announced = Date.now() - turnSignalAt < TURN_SIGNAL_MS;
|
||||||
|
// The viewport has stopped moving, so what it is now is better than
|
||||||
|
// what the pin guessed it would be. Letting go here, with the realign
|
||||||
|
// below in the same frame, is the one step the picture takes.
|
||||||
|
if (turnPin && !announced && settleStableFrames >= TURN_HOLD_FRAMES) {
|
||||||
|
releaseTurn();
|
||||||
|
turnResolved = true;
|
||||||
|
}
|
||||||
|
realignToActive();
|
||||||
|
// The window may not conclude mid-turn either: closing it releases the
|
||||||
|
// pin on the way out, which would put the picture through the same
|
||||||
|
// intermediate shape by the other door. An announced turn therefore
|
||||||
|
// holds the window open too, and SETTLE_MAX_MS -- longer than the
|
||||||
|
// announcement -- is what still guarantees it ends.
|
||||||
|
if ((announced || settleStableFrames < SETTLE_STABLE_FRAMES) &&
|
||||||
|
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
|
||||||
|
resizeSettleRaf = requestAnimationFrame(settleStep);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// One more frame, so a re-snap the container has already scheduled
|
||||||
|
// lands inside the window rather than just after it.
|
||||||
|
resizeSettleRaf = requestAnimationFrame(finishSettle);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The device announcing the turn: dim first, measure after. Nothing about
|
||||||
|
// the viewport has changed yet at this point, so the reel is already on
|
||||||
|
// its way out when the first new shape lands.
|
||||||
|
const onOrientation = function() {
|
||||||
|
turnSignalAt = Date.now();
|
||||||
|
turnResolved = false;
|
||||||
|
if (state.feedOpen && state.feedActiveIndex >= 0 && !turnPin) {
|
||||||
|
pinTurn(viewportShape());
|
||||||
|
}
|
||||||
|
onResize();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResize = function() {
|
const onResize = function() {
|
||||||
@@ -1091,18 +1564,26 @@ App.feed = App.feed || {};
|
|||||||
// Suppress scroll handling while we realign so the container's re-snap
|
// Suppress scroll handling while we realign so the container's re-snap
|
||||||
// doesn't flip the active video, then re-enable it once layout settles.
|
// doesn't flip the active video, then re-enable it once layout settles.
|
||||||
suppressScroll = true;
|
suppressScroll = true;
|
||||||
|
// The shape to compare against is the one from before the turn
|
||||||
|
// started, not the one from the previous step of it.
|
||||||
|
if (!resizeSettleTimer) {
|
||||||
|
settleFromShape = lastShape || viewportShape();
|
||||||
|
turnResolved = false;
|
||||||
|
}
|
||||||
|
settleStartedAt = Date.now();
|
||||||
|
settleStableFrames = 0;
|
||||||
|
settleHeight = -1;
|
||||||
|
lastMeasuredHeight = -1;
|
||||||
|
if (!turnPin && !turnResolved && isTurn(settleFromShape, viewportShape())) {
|
||||||
|
pinTurn(settleFromShape);
|
||||||
|
}
|
||||||
realignToActive();
|
realignToActive();
|
||||||
// Orientation changes can settle over more than one frame (the visual
|
|
||||||
// viewport and the scroll-snap re-anchor in stages); realign again once
|
|
||||||
// layout has settled, then stop suppressing real swipes.
|
|
||||||
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
|
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
|
||||||
resizeSettleRaf = requestAnimationFrame(() => {
|
resizeSettleRaf = requestAnimationFrame(settleStep);
|
||||||
realignToActive();
|
// Frames stop in a hidden tab, which would leave the window open for
|
||||||
resizeSettleRaf = requestAnimationFrame(() => {
|
// as long as the tab stays away; the clock closes it regardless.
|
||||||
resizeSettleRaf = null;
|
if (resizeSettleTimer) clearTimeout(resizeSettleTimer);
|
||||||
suppressScroll = false;
|
resizeSettleTimer = setTimeout(finishSettle, SETTLE_MAX_MS + 500);
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -1171,7 +1652,7 @@ App.feed = App.feed || {};
|
|||||||
const titleText = pane.querySelector('.feed-title-text');
|
const titleText = pane.querySelector('.feed-title-text');
|
||||||
if (titleText) titleText.textContent = v.title || '';
|
if (titleText) titleText.textContent = v.title || '';
|
||||||
const poster = pane.querySelector('.feed-poster');
|
const poster = pane.querySelector('.feed-poster');
|
||||||
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
|
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||||
loadSlideSource(pane, v, true);
|
loadSlideSource(pane, v, true);
|
||||||
setPipMetadata(v);
|
setPipMetadata(v);
|
||||||
};
|
};
|
||||||
@@ -1332,21 +1813,21 @@ App.feed = App.feed || {};
|
|||||||
// recomputes them from the active video's id -- the same thing an
|
// recomputes them from the active video's id -- the same thing an
|
||||||
// orientation change does.
|
// orientation change does.
|
||||||
const settleAfterMove = function() {
|
const settleAfterMove = function() {
|
||||||
suppressScroll = true;
|
const resume = function() {
|
||||||
realignToActive();
|
const slide = slidesByIndex.get(state.feedActiveIndex);
|
||||||
requestAnimationFrame(() => {
|
if (slide) panesOf(slide).forEach((pane) => {
|
||||||
realignToActive();
|
const video = pane.querySelector('.feed-video');
|
||||||
requestAnimationFrame(() => {
|
if (video && video.paused && pane.classList.contains('is-loaded')) {
|
||||||
suppressScroll = false;
|
video.play().catch(() => {});
|
||||||
const slide = slidesByIndex.get(state.feedActiveIndex);
|
}
|
||||||
if (slide) panesOf(slide).forEach((pane) => {
|
|
||||||
const video = pane.querySelector('.feed-video');
|
|
||||||
if (video && video.paused && pane.classList.contains('is-loaded')) {
|
|
||||||
video.play().catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
if (!state.feedOpen || state.feedActiveIndex < 0) {
|
||||||
|
resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settleDone = resume;
|
||||||
|
onResize();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Puts the feed back in the page. Called when the window closes, however it
|
// Puts the feed back in the page. Called when the window closes, however it
|
||||||
@@ -1484,6 +1965,8 @@ App.feed = App.feed || {};
|
|||||||
slide.remove();
|
slide.remove();
|
||||||
});
|
});
|
||||||
slidesByIndex.clear();
|
slidesByIndex.clear();
|
||||||
|
forgetPeeks();
|
||||||
|
releaseTurn();
|
||||||
resumeTimes.clear();
|
resumeTimes.clear();
|
||||||
state.feedActiveIndex = -1;
|
state.feedActiveIndex = -1;
|
||||||
state.feedActiveVideoId = null;
|
state.feedActiveVideoId = null;
|
||||||
@@ -1492,6 +1975,10 @@ App.feed = App.feed || {};
|
|||||||
cancelAnimationFrame(resizeSettleRaf);
|
cancelAnimationFrame(resizeSettleRaf);
|
||||||
resizeSettleRaf = null;
|
resizeSettleRaf = null;
|
||||||
}
|
}
|
||||||
|
if (resizeSettleTimer) {
|
||||||
|
clearTimeout(resizeSettleTimer);
|
||||||
|
resizeSettleTimer = null;
|
||||||
|
}
|
||||||
const spacer = getTopSpacer();
|
const spacer = getTopSpacer();
|
||||||
if (spacer) spacer.style.height = '0px';
|
if (spacer) spacer.style.height = '0px';
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
@@ -1515,12 +2002,34 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
container.classList.add('open');
|
container.classList.add('open');
|
||||||
container.setAttribute('aria-hidden', 'false');
|
container.setAttribute('aria-hidden', 'false');
|
||||||
|
lastShape = viewportShape();
|
||||||
document.body.classList.add('feed-mode-open');
|
document.body.classList.add('feed-mode-open');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
if (!scrollBound) {
|
if (!scrollBound) {
|
||||||
scroller.addEventListener('scroll', onScroll, { passive: true });
|
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
// How the realign window tells a swipe from the container
|
||||||
|
// re-anchoring itself (see suppressScroll): a scroll with one of
|
||||||
|
// these behind it is the reader's, and outranks the window.
|
||||||
|
// Only input that means to scroll counts -- a finger that has
|
||||||
|
// moved, not one that has merely landed, since a tap must not
|
||||||
|
// sign for the re-anchor that follows it.
|
||||||
|
const noteInput = () => { lastInputAt = Date.now(); };
|
||||||
|
['wheel', 'touchmove'].forEach((type) => {
|
||||||
|
scroller.addEventListener(type, noteInput, { passive: true });
|
||||||
|
});
|
||||||
|
// Arrow keys and Page Up/Down scroll the container too, and the
|
||||||
|
// keystroke lands on whatever has focus rather than the scroller.
|
||||||
|
window.addEventListener('keydown', noteInput, { passive: true });
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
|
// `orientationchange` fires before the new metrics are readable and
|
||||||
|
// the visual viewport moves on its own as the browser's chrome
|
||||||
|
// slides away, so both open the realign window too -- the watcher
|
||||||
|
// below waits for the height to stop moving either way.
|
||||||
|
window.addEventListener('orientationchange', onOrientation);
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', onResize);
|
||||||
|
}
|
||||||
scrollBound = true;
|
scrollBound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1533,8 +2042,18 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
if (!hudActivityBound) {
|
if (!hudActivityBound) {
|
||||||
container.addEventListener('mousemove', wakeHud, { passive: true });
|
container.addEventListener('mousemove', wakeHud, { passive: true });
|
||||||
container.addEventListener('pointerdown', wakeHud, { passive: true });
|
// A press on the video is how a swipe begins, and waking on that
|
||||||
container.addEventListener('touchstart', wakeHud, { passive: true });
|
// flashed the HUD at the start of every drag -- a press that turns
|
||||||
|
// out to be a tap wakes it through the gesture layer's onSingleTap
|
||||||
|
// instead. A press on a control is not a swipe, and the control it
|
||||||
|
// lands on is one the reader can't see while the HUD is hidden.
|
||||||
|
container.addEventListener('pointerdown', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target && target.closest &&
|
||||||
|
target.closest('button, input, a, .feed-timeline, .feed-format-menu')) {
|
||||||
|
wakeHud();
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
hudActivityBound = true;
|
hudActivityBound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1553,7 +2072,10 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
App.feed.updateToggleButton();
|
App.feed.updateToggleButton();
|
||||||
App.feed.updateMuteButton();
|
App.feed.updateMuteButton();
|
||||||
wakeHud();
|
// The scroll assignment above fires a scroll event, which puts the HUD
|
||||||
|
// away; the controls should be up when the feed opens, so this waits
|
||||||
|
// for that to land (scroll events run before animation frames).
|
||||||
|
requestAnimationFrame(wakeHud);
|
||||||
};
|
};
|
||||||
|
|
||||||
App.feed.close = function() {
|
App.feed.close = function() {
|
||||||
@@ -1585,6 +2107,9 @@ App.feed = App.feed || {};
|
|||||||
container.setAttribute('aria-hidden', 'true');
|
container.setAttribute('aria-hidden', 'true');
|
||||||
document.body.classList.remove('feed-mode-open');
|
document.body.classList.remove('feed-mode-open');
|
||||||
document.body.style.overflow = 'auto';
|
document.body.style.overflow = 'auto';
|
||||||
|
// The grid reveals a page at a time and is back in charge of the
|
||||||
|
// buffer: the feed's deeper one was for swipes eating N videos each.
|
||||||
|
if (App.videos.setPrefetchDepth) App.videos.setPrefetchDepth(1);
|
||||||
App.feed.updateToggleButton();
|
App.feed.updateToggleButton();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ window.App = window.App || {};
|
|||||||
(function() {
|
(function() {
|
||||||
// App bootstrap: initialize storage, render UI, and load the first page.
|
// App bootstrap: initialize storage, render UI, and load the first page.
|
||||||
async function initApp() {
|
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();
|
await App.storage.ensureDefaults();
|
||||||
App.ui.applyTheme();
|
App.ui.applyTheme();
|
||||||
App.ui.applyPreferredQuality();
|
App.ui.applyPreferredQuality();
|
||||||
@@ -39,6 +43,12 @@ window.App = window.App || {};
|
|||||||
await App.videos.loadVideos();
|
await App.videos.loadVideos();
|
||||||
App.favorites.syncButtons();
|
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
|
// 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
|
// localStorage, so startup never blocks on (or breaks because of) a slow
|
||||||
// or failing status endpoint. Now fetch fresh status in the background and
|
// or failing status endpoint. Now fetch fresh status in the background and
|
||||||
|
|||||||
@@ -830,7 +830,7 @@ App.player = App.player || {};
|
|||||||
// a browser, so there is nothing to find out.
|
// a browser, so there is nothing to find out.
|
||||||
if (resolved.refererRequired) return;
|
if (resolved.refererRequired) return;
|
||||||
if (directProven(resolved.url)) 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;
|
if (!ok || token !== cp.attemptToken) return;
|
||||||
// readyState >= HAVE_CURRENT_DATA means a frame is up; leave a
|
// readyState >= HAVE_CURRENT_DATA means a frame is up; leave a
|
||||||
// playing video alone rather than trading a visible stall for a
|
// playing video alone rather than trading a visible stall for a
|
||||||
|
|||||||
583
frontend/js/posters.js
Normal file
583
frontend/js/posters.js
Normal 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
60
frontend/js/privacy.js
Normal 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; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
726
frontend/js/savedSearches.js
Normal file
726
frontend/js/savedSearches.js
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
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;
|
||||||
|
// So does a merge, of each of its searches.
|
||||||
|
const MERGED_PROBE_PER_PAGE = 6;
|
||||||
|
// 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 ----------------------------------------------------------
|
||||||
|
|
||||||
|
// One search's worth of "where to look": a source, a channel on it, that
|
||||||
|
// channel's filters and the words. A plain saved search is one of these
|
||||||
|
// held flat on the entry; a merged one is a list of them.
|
||||||
|
const cleanMember = function(member, i) {
|
||||||
|
if (!member || typeof member !== 'object') return null;
|
||||||
|
if (!member.server || !member.channelId) return null;
|
||||||
|
return {
|
||||||
|
memberId: String(member.memberId || `m${i}`),
|
||||||
|
server: String(member.server),
|
||||||
|
channelId: String(member.channelId),
|
||||||
|
channelName: typeof member.channelName === 'string' ? member.channelName : '',
|
||||||
|
query: typeof member.query === 'string' ? member.query : '',
|
||||||
|
options: member.options && typeof member.options === 'object' ? member.options : {},
|
||||||
|
optionLabels: Array.isArray(member.optionLabels) ? member.optionLabels.map(String) : []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clean = function(entry) {
|
||||||
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
|
if (!entry.id) return null;
|
||||||
|
// A merge keeps its members and no flat source of its own: there is no
|
||||||
|
// single server or channel that would be the truth for it.
|
||||||
|
if (Array.isArray(entry.members) && entry.members.length) {
|
||||||
|
const members = entry.members.map(cleanMember).filter(Boolean);
|
||||||
|
if (!members.length) return null;
|
||||||
|
return {
|
||||||
|
id: String(entry.id),
|
||||||
|
name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Merged search',
|
||||||
|
members: members,
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!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;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.savedSearches.isMerged = function(entry) {
|
||||||
|
return !!(entry && Array.isArray(entry.members) && entry.members.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every entry read as a list of places to look, so the code that asks a
|
||||||
|
// source something does not have to care which kind it was handed. A plain
|
||||||
|
// search is a merge of one.
|
||||||
|
App.savedSearches.membersOf = function(entry) {
|
||||||
|
if (!entry) return [];
|
||||||
|
if (App.savedSearches.isMerged(entry)) return entry.members;
|
||||||
|
return [{
|
||||||
|
memberId: 'm0',
|
||||||
|
server: entry.server,
|
||||||
|
channelId: entry.channelId,
|
||||||
|
channelName: entry.channelName,
|
||||||
|
query: entry.query || '',
|
||||||
|
options: entry.options || {},
|
||||||
|
optionLabels: entry.optionLabels || []
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
// A merge is described by the searches it is made of, not by the
|
||||||
|
// session's server and channel -- there is no one of either. Its
|
||||||
|
// members are what the bookmark has to recognise it by, whichever of
|
||||||
|
// them happen to be toggled on just now.
|
||||||
|
if (session.channel.isMerged) {
|
||||||
|
return {
|
||||||
|
name: session.channel.name,
|
||||||
|
savedSearchId: session.channel.savedSearchId,
|
||||||
|
members: (session.channel.members || []).map((member) => ({
|
||||||
|
memberId: member.memberId,
|
||||||
|
server: member.server,
|
||||||
|
channelId: member.channelId,
|
||||||
|
channelName: member.channelName,
|
||||||
|
query: member.query || '',
|
||||||
|
options: member.options || {},
|
||||||
|
optionLabels: member.optionLabels || []
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// One member's worth of identity: the request it would send.
|
||||||
|
const memberSignature = function(member) {
|
||||||
|
const options = member.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 [member.server, member.channelId, (member.query || '').toLowerCase().trim()]
|
||||||
|
.concat(parts).join('||');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two searches are the same search when they would send the same request.
|
||||||
|
// A merge is the same merge when it is made of the same searches -- in any
|
||||||
|
// order, since which one was ticked first is not part of what it is.
|
||||||
|
App.savedSearches.signature = function(desc) {
|
||||||
|
if (!desc) return '';
|
||||||
|
if (Array.isArray(desc.members) && desc.members.length) {
|
||||||
|
return `merged::${desc.members.map(memberSignature).sort().join('&&')}`;
|
||||||
|
}
|
||||||
|
return memberSignature(desc);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// What to call a merge nobody has named: the searches in it, which is the
|
||||||
|
// only thing it is. Two names fit on a row; past that it counts.
|
||||||
|
const mergedAutoName = function(members) {
|
||||||
|
const names = members.map((member) => member.channelName || member.channelId);
|
||||||
|
if (names.length <= 2) return names.join(' + ');
|
||||||
|
return `${names[0]} + ${names.length - 1} more`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoName = function(desc) {
|
||||||
|
if (Array.isArray(desc.members) && desc.members.length) return mergedAutoName(desc.members);
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merges several saved searches into one listing whose results come from
|
||||||
|
// all of them at once. The sources need nothing in common: that is the
|
||||||
|
// point, and it is why this cannot be a channel group (a group lives on
|
||||||
|
// one server and shares one query).
|
||||||
|
//
|
||||||
|
// The searches that went in are replaced by the merge. Merging a merge
|
||||||
|
// flattens it rather than nesting, so a merge is always one level deep and
|
||||||
|
// "the searches in it" means the same thing however it was assembled.
|
||||||
|
App.savedSearches.merge = function(ids, name) {
|
||||||
|
const wanted = Array.isArray(ids) ? ids : [];
|
||||||
|
const entries = App.savedSearches.getAll();
|
||||||
|
const chosen = wanted
|
||||||
|
.map((id) => entries.find((entry) => entry.id === id))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (chosen.length < 2) return null;
|
||||||
|
|
||||||
|
// Deduped by what each member would ask for, so merging two searches
|
||||||
|
// that happen to be the same search does not ask twice.
|
||||||
|
const members = [];
|
||||||
|
const seenSigs = new Set();
|
||||||
|
chosen.forEach((entry) => {
|
||||||
|
App.savedSearches.membersOf(entry).forEach((member) => {
|
||||||
|
const sig = memberSignature(member);
|
||||||
|
if (seenSigs.has(sig)) return;
|
||||||
|
seenSigs.add(sig);
|
||||||
|
members.push(Object.assign({}, member, { memberId: `m${members.length}` }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (members.length < 2) return null;
|
||||||
|
|
||||||
|
const entry = clean({
|
||||||
|
id: `ss_${now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
name: (name || '').trim() || mergedAutoName(members),
|
||||||
|
members: members,
|
||||||
|
createdAt: now(),
|
||||||
|
// No mark yet, so the first check sets one instead of counting a
|
||||||
|
// whole first page as news (see checkOne).
|
||||||
|
seenIds: [],
|
||||||
|
lastViewedAt: 0,
|
||||||
|
lastCheckedAt: 0,
|
||||||
|
newCount: 0
|
||||||
|
});
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
const chosenIds = new Set(chosen.map((item) => item.id));
|
||||||
|
const kept = entries.filter((item) => !chosenIds.has(item.id));
|
||||||
|
// Where the first of them was, rather than at the top: the reader put
|
||||||
|
// the list in the order they wanted.
|
||||||
|
const at = entries.findIndex((item) => chosenIds.has(item.id));
|
||||||
|
kept.splice(at < 0 ? 0 : at, 0, entry);
|
||||||
|
setAll(kept);
|
||||||
|
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.
|
||||||
|
// One from each list in turn, so "the newest" means here what it means in
|
||||||
|
// the grid: the sources taken alternately rather than one exhausted first.
|
||||||
|
const interleave = function(lists) {
|
||||||
|
const out = [];
|
||||||
|
const longest = lists.reduce((max, ids) => Math.max(max, ids.length), 0);
|
||||||
|
for (let i = 0; i < longest; i++) {
|
||||||
|
lists.forEach((ids) => { if (ids[i]) out.push(ids[i]); });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The first page of one member -- one source, one channel on it, its own
|
||||||
|
// filters and its own words. A member may itself name a channel group, in
|
||||||
|
// which case its own channels are interleaved first.
|
||||||
|
const probeMember = async function(member, perPage, signal) {
|
||||||
|
const serverEntry = App.storage.getServerEntries()
|
||||||
|
.find((candidate) => candidate.url === member.server);
|
||||||
|
const serverData = serverEntry ? serverEntry.data : null;
|
||||||
|
const channel = serverData ? App.session.resolveChannelById(serverData, member.channelId) : null;
|
||||||
|
if (!channel) throw new Error('channel is no longer listed');
|
||||||
|
|
||||||
|
const ask = async function(channelId, size, extra) {
|
||||||
|
const body = Object.assign({
|
||||||
|
channel: channelId,
|
||||||
|
query: member.query || '',
|
||||||
|
page: 1,
|
||||||
|
perPage: size,
|
||||||
|
server: member.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 = member.options ? member.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 [];
|
||||||
|
return interleave(await Promise.all(
|
||||||
|
enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const extra = {};
|
||||||
|
Object.entries(member.options || {}).forEach(([key, value]) => {
|
||||||
|
extra[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||||
|
});
|
||||||
|
return ask(member.channelId, perPage, extra);
|
||||||
|
};
|
||||||
|
|
||||||
|
const probeIds = async function(entry, signal) {
|
||||||
|
const members = App.savedSearches.membersOf(entry);
|
||||||
|
if (members.length === 1) return probeMember(members[0], PROBE_PER_PAGE, signal);
|
||||||
|
// A merge asks every member and interleaves them, so the badge counts
|
||||||
|
// what the grid would actually have put in front of the reader. One
|
||||||
|
// member whose source has gone is not the end of the merge: the rest
|
||||||
|
// still answer, and only a merge where nothing answers is a failure.
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
members.map((member) => probeMember(member, MERGED_PROBE_PER_PAGE, signal)));
|
||||||
|
const ok = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
||||||
|
if (!ok.length) {
|
||||||
|
const first = settled.find((r) => r.status === 'rejected');
|
||||||
|
throw (first && first.reason) || new Error('no member could be reached');
|
||||||
|
}
|
||||||
|
return interleave(ok);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 -----------------------------------------------------
|
||||||
|
|
||||||
|
// Opens a merge: one listing fed by all of its searches at once.
|
||||||
|
//
|
||||||
|
// The search box is left empty and stays out of it. Every member carries
|
||||||
|
// its own words -- that is what makes merging two different searches worth
|
||||||
|
// doing -- so there is nothing for one box to say for all of them.
|
||||||
|
//
|
||||||
|
// Not saved as a channel preference either: a preference is stored per
|
||||||
|
// server against a channel that server lists, and a merge is neither.
|
||||||
|
// Reload restores it from the session instead (see ensureSessionFromCache).
|
||||||
|
const applyMerged = async function(entry) {
|
||||||
|
const channel = App.session.buildMergedChannel(entry);
|
||||||
|
if (!channel) return false;
|
||||||
|
|
||||||
|
const configured = new Set(App.storage.getServerEntries()
|
||||||
|
.filter((candidate) => candidate.data)
|
||||||
|
.map((candidate) => candidate.url));
|
||||||
|
const reachable = channel.members.filter((member) => configured.has(member.server));
|
||||||
|
if (!reachable.length) {
|
||||||
|
App.ui.showError(`${entry.name}: none of its sources are set up on this device any more.`);
|
||||||
|
patch(entry.id, { unreachable: true });
|
||||||
|
changed();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (reachable.length < channel.members.length) {
|
||||||
|
const missing = channel.members
|
||||||
|
.filter((member) => !configured.has(member.server))
|
||||||
|
.map((member) => member.server);
|
||||||
|
App.ui.showError(`${entry.name}: showing it without ${missing.join(', ')} — not set up on this device.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
App.storage.setSession({
|
||||||
|
server: reachable[0].server,
|
||||||
|
channel: channel,
|
||||||
|
options: App.session.buildDefaultOptions(channel)
|
||||||
|
});
|
||||||
|
|
||||||
|
App.videos.setSearchBox('');
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (App.savedSearches.isMerged(entry)) return applyMerged(entry);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -15,7 +15,8 @@ App.state = {
|
|||||||
feedMuted: true,
|
feedMuted: true,
|
||||||
feedActiveIndex: -1,
|
feedActiveIndex: -1,
|
||||||
feedActiveVideoId: null,
|
feedActiveVideoId: null,
|
||||||
groupCursors: null
|
groupCursors: null,
|
||||||
|
mergedCursors: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local storage keys used across modules.
|
// Local storage keys used across modules.
|
||||||
@@ -24,7 +25,12 @@ App.constants = {
|
|||||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||||
FAVORITES_SORT_KEY: 'favoritesSort',
|
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
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
|
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||||
|
|||||||
@@ -48,6 +48,73 @@ App.session = App.session || {};
|
|||||||
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
|
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).
|
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
|
||||||
App.storage.getDensity = function() {
|
App.storage.getDensity = function() {
|
||||||
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
|
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
|
||||||
@@ -97,6 +164,7 @@ App.session = App.session || {};
|
|||||||
// channels as a toggleable multi-select, so the user can browse "All <group>"
|
// channels as a toggleable multi-select, so the user can browse "All <group>"
|
||||||
// while disabling individual channels.
|
// while disabling individual channels.
|
||||||
App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels';
|
App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels';
|
||||||
|
App.session.MERGED_MEMBERS_OPTION_ID = '__mergedMembers';
|
||||||
|
|
||||||
// Options/session helpers that power channel selection and filters.
|
// Options/session helpers that power channel selection and filters.
|
||||||
App.session.serializeOptions = function(options) {
|
App.session.serializeOptions = function(options) {
|
||||||
@@ -166,6 +234,45 @@ App.session = App.session || {};
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Builds a pseudo-channel representing a merged saved search: several
|
||||||
|
// saved searches, each with its own source, channel, filters and words,
|
||||||
|
// browsed as one listing. A channel group does something similar but
|
||||||
|
// cannot express this -- every channel in a group belongs to the same
|
||||||
|
// server and shares one query, where a merge is held together by nothing
|
||||||
|
// except the reader having put it together (see fetchMergedBatch).
|
||||||
|
//
|
||||||
|
// The members are carried on the channel itself rather than looked up from
|
||||||
|
// a server's status, because no single server knows about them. That is
|
||||||
|
// also what lets a merged session survive a reload.
|
||||||
|
App.session.buildMergedChannel = function(entry) {
|
||||||
|
if (!entry || !Array.isArray(entry.members) || entry.members.length === 0) return null;
|
||||||
|
const members = entry.members.map((member, i) => Object.assign({}, member, {
|
||||||
|
// Stable within the merge, so a member toggle keeps meaning the
|
||||||
|
// same member even if two of them name the same channel.
|
||||||
|
memberId: member.memberId || `m${i}`
|
||||||
|
}));
|
||||||
|
const memberOptions = members.map((member) => ({
|
||||||
|
id: member.memberId,
|
||||||
|
title: member.channelName || member.channelId
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: `merged:${entry.id}`,
|
||||||
|
name: entry.name || 'Merged search',
|
||||||
|
isMerged: true,
|
||||||
|
savedSearchId: entry.id,
|
||||||
|
members: members,
|
||||||
|
// Same shape as a group's "Channels" filter, so the existing
|
||||||
|
// filter UI shows it without knowing what a merge is.
|
||||||
|
options: memberOptions.length > 0 ? [{
|
||||||
|
id: App.session.MERGED_MEMBERS_OPTION_ID,
|
||||||
|
title: 'Searches',
|
||||||
|
multiSelect: true,
|
||||||
|
selectAllDefault: true,
|
||||||
|
options: memberOptions
|
||||||
|
}] : []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Resolves a stored channel id (which may reference a single channel or a
|
// Resolves a stored channel id (which may reference a single channel or a
|
||||||
// "group:<id>" pseudo-channel) against a server's status payload.
|
// "group:<id>" pseudo-channel) against a server's status payload.
|
||||||
App.session.resolveChannelById = function(serverData, channelId) {
|
App.session.resolveChannelById = function(serverData, channelId) {
|
||||||
@@ -283,6 +390,28 @@ App.session = App.session || {};
|
|||||||
const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]);
|
const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]);
|
||||||
const existingSession = App.storage.getSession();
|
const existingSession = App.storage.getSession();
|
||||||
|
|
||||||
|
// A merged search answers to no single server, so the check below --
|
||||||
|
// "does this server still list this channel" -- cannot speak for it.
|
||||||
|
// It is valid while the saved search it came from is still there and
|
||||||
|
// at least one member's source is still set up. Rebuilt from that
|
||||||
|
// saved search rather than trusted as stored, so a rename or an edited
|
||||||
|
// member is picked up instead of being frozen into the session.
|
||||||
|
if (existingSession && existingSession.channel && existingSession.channel.isMerged) {
|
||||||
|
const stored = App.savedSearches && typeof App.savedSearches.get === 'function'
|
||||||
|
? App.savedSearches.get(existingSession.channel.savedSearchId)
|
||||||
|
: null;
|
||||||
|
const channel = stored ? App.session.buildMergedChannel(stored) : existingSession.channel;
|
||||||
|
const members = (channel && channel.members) || [];
|
||||||
|
if (members.some((member) => serverKeys.includes(member.server))) {
|
||||||
|
App.storage.setSession({
|
||||||
|
server: members[0].server,
|
||||||
|
channel: channel,
|
||||||
|
options: App.session.hydrateOptions(channel, App.session.serializeOptions(existingSession.options))
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Leave a still-valid session untouched so we don't disturb the user's
|
// Leave a still-valid session untouched so we don't disturb the user's
|
||||||
// current server/channel selection on refresh.
|
// current server/channel selection on refresh.
|
||||||
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {
|
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {
|
||||||
|
|||||||
@@ -288,6 +288,11 @@ App.ui = App.ui || {};
|
|||||||
if (otherBtn) otherBtn.classList.remove('active');
|
if (otherBtn) otherBtn.classList.remove('active');
|
||||||
|
|
||||||
if (willOpen) {
|
if (willOpen) {
|
||||||
|
// What the captured-thumbnail note says is a count, and captures
|
||||||
|
// happen while the drawer is shut, so it is read as it opens.
|
||||||
|
if (!isMenu && typeof App.ui.syncPosterCache === 'function') {
|
||||||
|
App.ui.syncPosterCache();
|
||||||
|
}
|
||||||
targetDrawer.classList.add('open');
|
targetDrawer.classList.add('open');
|
||||||
if (targetBtn) targetBtn.classList.add('active');
|
if (targetBtn) targetBtn.classList.add('active');
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
@@ -297,6 +302,740 @@ App.ui = App.ui || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Recent searches
|
||||||
|
//
|
||||||
|
// The searches from before, offered under the box as you type. Which ones
|
||||||
|
// and in what order is App.storage's business (suggestSearches); this is
|
||||||
|
// the list itself: when it shows, how it is walked, and what taking one
|
||||||
|
// does. Five at a time, because a list you have to read is not a shortcut.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
App.ui.searchHistory = (function() {
|
||||||
|
const SHOWN = 5;
|
||||||
|
let offered = [];
|
||||||
|
let activeIndex = -1;
|
||||||
|
let bound = false;
|
||||||
|
|
||||||
|
const input = () => document.getElementById('search-input');
|
||||||
|
const list = () => document.getElementById('search-suggest');
|
||||||
|
|
||||||
|
const isOpen = function() {
|
||||||
|
const el = list();
|
||||||
|
return !!el && !el.hidden;
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = function() {
|
||||||
|
const el = list();
|
||||||
|
if (el) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = ''; // no rows left behind for anything to find
|
||||||
|
}
|
||||||
|
offered = [];
|
||||||
|
activeIndex = -1;
|
||||||
|
const box = input();
|
||||||
|
if (box) {
|
||||||
|
box.setAttribute('aria-expanded', 'false');
|
||||||
|
box.removeAttribute('aria-activedescendant');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The typed part, picked out of the suggestion it matched. Built as
|
||||||
|
// text nodes rather than markup: these strings are the reader's own,
|
||||||
|
// and they are going back on screen.
|
||||||
|
const rowLabel = function(entry, typed) {
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'search-suggest-label';
|
||||||
|
const at = typed ? entry.toLowerCase().indexOf(typed.toLowerCase()) : -1;
|
||||||
|
if (at < 0) {
|
||||||
|
label.textContent = entry;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
label.appendChild(document.createTextNode(entry.slice(0, at)));
|
||||||
|
const hit = document.createElement('span');
|
||||||
|
hit.className = 'search-suggest-hit';
|
||||||
|
hit.textContent = entry.slice(at, at + typed.length);
|
||||||
|
label.appendChild(hit);
|
||||||
|
label.appendChild(document.createTextNode(entry.slice(at + typed.length)));
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlight = function(next) {
|
||||||
|
const el = list();
|
||||||
|
if (!el) return;
|
||||||
|
const rows = Array.from(el.querySelectorAll('.search-suggest-row'));
|
||||||
|
activeIndex = next;
|
||||||
|
let activeId = '';
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
const on = i === activeIndex;
|
||||||
|
row.classList.toggle('is-active', on);
|
||||||
|
row.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||||
|
if (on) activeId = row.id;
|
||||||
|
});
|
||||||
|
const box = input();
|
||||||
|
if (!box) return;
|
||||||
|
if (activeId) box.setAttribute('aria-activedescendant', activeId);
|
||||||
|
else box.removeAttribute('aria-activedescendant');
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = function(query) {
|
||||||
|
const box = input();
|
||||||
|
close();
|
||||||
|
if (!query) return;
|
||||||
|
if (box) {
|
||||||
|
box.value = query;
|
||||||
|
box.blur(); // the list has served its purpose; give the page back
|
||||||
|
}
|
||||||
|
App.videos.handleSearch(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shows what the box's current contents suggest, or hides the list when
|
||||||
|
// nothing does.
|
||||||
|
const refresh = function() {
|
||||||
|
const box = input();
|
||||||
|
const el = list();
|
||||||
|
if (!box || !el) return;
|
||||||
|
const typed = box.value.trim();
|
||||||
|
offered = App.storage.suggestSearches(typed, SHOWN);
|
||||||
|
if (!offered.length) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.textContent = '';
|
||||||
|
offered.forEach((entry, i) => {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'search-suggest-row';
|
||||||
|
row.id = 'search-suggest-' + i;
|
||||||
|
row.setAttribute('role', 'option');
|
||||||
|
row.setAttribute('aria-selected', 'false');
|
||||||
|
row.dataset.query = entry;
|
||||||
|
row.appendChild(rowLabel(entry, typed));
|
||||||
|
// Chosen on pointerdown, before the box loses focus: a click
|
||||||
|
// would arrive after the blur that closes the list.
|
||||||
|
row.addEventListener('pointerdown', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(entry);
|
||||||
|
});
|
||||||
|
row.addEventListener('mousemove', () => highlight(i));
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
el.hidden = false;
|
||||||
|
box.setAttribute('aria-expanded', 'true');
|
||||||
|
highlight(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = function(event) {
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
if (!isOpen()) {
|
||||||
|
refresh();
|
||||||
|
if (!isOpen()) return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||||
|
const count = offered.length;
|
||||||
|
highlight(((activeIndex + step) % count + count) % count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
const box = input();
|
||||||
|
if (isOpen() && activeIndex >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(offered[activeIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Typed and entered: a search the reader meant, so it is kept
|
||||||
|
// now rather than waiting for the typing to settle.
|
||||||
|
close();
|
||||||
|
if (box) {
|
||||||
|
App.storage.rememberSearch(box.value);
|
||||||
|
App.videos.handleSearch(box.value);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape' && isOpen()) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation(); // Escape closes drawers too
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bind = function() {
|
||||||
|
const box = input();
|
||||||
|
if (bound || !box) return;
|
||||||
|
bound = true;
|
||||||
|
box.addEventListener('focus', refresh);
|
||||||
|
box.addEventListener('input', refresh);
|
||||||
|
box.addEventListener('keydown', onKeyDown);
|
||||||
|
box.addEventListener('blur', () => { window.setTimeout(close, 0); });
|
||||||
|
document.addEventListener('pointerdown', (event) => {
|
||||||
|
if (!isOpen()) return;
|
||||||
|
if (event.target.closest('.search-container')) return;
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Saved searches
|
||||||
|
//
|
||||||
|
// The bookmark in the search bar keeps whatever is on screen -- source,
|
||||||
|
// channel, filters, query -- and the bookmark in the header opens the
|
||||||
|
// list of them. Each row carries what is new in it since it was last
|
||||||
|
// opened (App.savedSearches does the counting), and tapping a row puts
|
||||||
|
// that whole search back.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
App.ui.savedSearches = (function() {
|
||||||
|
let bound = false;
|
||||||
|
// A row with its rename field open, or its "remove?" question showing.
|
||||||
|
// Held here so a check landing mid-edit redraws around it rather than
|
||||||
|
// through it.
|
||||||
|
let pending = null;
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const panel = () => el('saved-panel');
|
||||||
|
const isOpen = () => !!panel() && panel().classList.contains('open');
|
||||||
|
|
||||||
|
const ago = function(stamp) {
|
||||||
|
if (!stamp) return 'never';
|
||||||
|
const seconds = Math.max(0, Math.round((Date.now() - stamp) / 1000));
|
||||||
|
if (seconds < 90) return 'just now';
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} h ago`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return days === 1 ? 'yesterday' : `${days} days ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where the search points, in one line: the channel, what the filters
|
||||||
|
// narrow it to, and the words themselves.
|
||||||
|
const hostOf = function(url) {
|
||||||
|
try { return new URL(url).host; } catch (err) { return String(url || ''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const metaLine = function(entry) {
|
||||||
|
// A merge has no channel or words of its own -- what it is, is the
|
||||||
|
// searches in it, so those are what the row says. Each one names
|
||||||
|
// where it looks and which source it looks on, because two members
|
||||||
|
// differing only by source is the whole reason to merge.
|
||||||
|
if (App.savedSearches.isMerged(entry)) {
|
||||||
|
const shown = entry.members.slice(0, 3).map((member) => {
|
||||||
|
const where = member.channelName || member.channelId;
|
||||||
|
const words = member.query ? ` “${member.query}”` : '';
|
||||||
|
return `${where}${words} · ${hostOf(member.server)}`;
|
||||||
|
});
|
||||||
|
const rest = entry.members.length - shown.length;
|
||||||
|
const tail = rest > 0 ? ` + ${rest} more` : '';
|
||||||
|
return `${entry.members.length} searches · ${shown.join(' + ')}${tail}`;
|
||||||
|
}
|
||||||
|
const parts = [entry.channelName || entry.channelId];
|
||||||
|
if (entry.optionLabels && entry.optionLabels.length) {
|
||||||
|
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
|
||||||
|
}
|
||||||
|
parts.push(entry.query ? `“${entry.query}”` : 'no search term');
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const stampLine = function(entry) {
|
||||||
|
if (entry.unreachable) return 'Could not be checked — the source did not answer.';
|
||||||
|
const checked = `Checked ${ago(entry.lastCheckedAt)}`;
|
||||||
|
if (!entry.newCount) return `${checked} · nothing new`;
|
||||||
|
const since = entry.lastViewedAt ? ` since you opened it ${ago(entry.lastViewedAt)}` : '';
|
||||||
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||||
|
return `${label} new${since} · ${checked.toLowerCase()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgIcon = function(paths, extraClass) {
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
svg.setAttribute('class', 'icon-line' + (extraClass ? ' ' + extraClass : ''));
|
||||||
|
paths.forEach((d) => {
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
svg.appendChild(path);
|
||||||
|
});
|
||||||
|
return svg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RENAME_ICON = ['M4.5 19.5h3L19 8a2.1 2.1 0 0 0-3-3L4.5 16.5v3z'];
|
||||||
|
const DELETE_ICON = ['M5 7.5h14', 'M9.5 7.5V5.75h5V7.5', 'M7 7.5l.8 11.2h8.4L17 7.5'];
|
||||||
|
|
||||||
|
const toolButton = function(act, label, paths) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'saved-tool';
|
||||||
|
button.dataset.act = act;
|
||||||
|
button.title = label;
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.appendChild(svgIcon(paths));
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A row mid-rename: the name becomes a field, and leaving it (Enter or
|
||||||
|
// blur) keeps what was typed. Escape puts the old name back -- the
|
||||||
|
// normal way round for an edit in place.
|
||||||
|
const renameField = function(entry, row) {
|
||||||
|
const field = document.createElement('input');
|
||||||
|
field.type = 'text';
|
||||||
|
field.className = 'saved-rename';
|
||||||
|
field.value = entry.name;
|
||||||
|
field.maxLength = 80;
|
||||||
|
field.setAttribute('aria-label', 'Name for this saved search');
|
||||||
|
let settled = false;
|
||||||
|
const commit = function(keep) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
pending = null;
|
||||||
|
if (keep) App.savedSearches.rename(entry.id, field.value);
|
||||||
|
else sync();
|
||||||
|
};
|
||||||
|
field.addEventListener('keydown', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === 'Enter') { event.preventDefault(); commit(true); }
|
||||||
|
else if (event.key === 'Escape') { event.preventDefault(); commit(false); }
|
||||||
|
});
|
||||||
|
field.addEventListener('blur', () => commit(true));
|
||||||
|
field.addEventListener('click', (event) => event.stopPropagation());
|
||||||
|
row.classList.add('is-renaming');
|
||||||
|
return field;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmStrip = function(entry) {
|
||||||
|
const strip = document.createElement('div');
|
||||||
|
strip.className = 'saved-confirm';
|
||||||
|
|
||||||
|
const question = document.createElement('span');
|
||||||
|
question.className = 'saved-confirm-text';
|
||||||
|
question.textContent = 'Remove this saved search?';
|
||||||
|
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.type = 'button';
|
||||||
|
remove.className = 'saved-confirm-btn is-danger';
|
||||||
|
remove.textContent = 'Remove';
|
||||||
|
remove.addEventListener('click', () => {
|
||||||
|
pending = null;
|
||||||
|
App.savedSearches.remove(entry.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const keep = document.createElement('button');
|
||||||
|
keep.type = 'button';
|
||||||
|
keep.className = 'saved-confirm-btn';
|
||||||
|
keep.textContent = 'Keep';
|
||||||
|
keep.addEventListener('click', () => { pending = null; sync(); });
|
||||||
|
|
||||||
|
strip.appendChild(question);
|
||||||
|
strip.appendChild(keep);
|
||||||
|
strip.appendChild(remove);
|
||||||
|
return strip;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Picking rows to merge. A merge is made here rather than from a row's
|
||||||
|
// own menu because it is about several rows at once -- and because
|
||||||
|
// "which ones" is the whole question.
|
||||||
|
let selecting = false;
|
||||||
|
let picked = new Set();
|
||||||
|
|
||||||
|
// Which shape a row is in. A row only has to be built again when it
|
||||||
|
// changes shape; otherwise the one on screen is updated where it
|
||||||
|
// stands (see render). Being picked is a shape too: the row swaps its
|
||||||
|
// count for a tick and its tools for nothing.
|
||||||
|
const modeOf = function(entry) {
|
||||||
|
if (selecting) return picked.has(entry.id) ? 'pick-on' : 'pick-off';
|
||||||
|
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRow = function(entry, index) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
// Only a row that was not on screen a moment ago animates in.
|
||||||
|
row.className = 'saved-row is-new';
|
||||||
|
row.dataset.id = entry.id;
|
||||||
|
row.dataset.mode = modeOf(entry);
|
||||||
|
row.style.setProperty('--i', String(index));
|
||||||
|
if (entry.unreachable) row.classList.add('is-unreachable');
|
||||||
|
|
||||||
|
const main = document.createElement('button');
|
||||||
|
main.type = 'button';
|
||||||
|
main.className = 'saved-row-main';
|
||||||
|
|
||||||
|
const count = document.createElement('span');
|
||||||
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||||
|
count.className = 'saved-count' + (label ? '' : ' is-quiet');
|
||||||
|
// The quiet state is a mark, not a number: a row with nothing new
|
||||||
|
// should not look like a row with something in it.
|
||||||
|
count.textContent = label || '·';
|
||||||
|
if (label) count.title = `${entry.newCount} new since you last opened this`;
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'saved-row-text';
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'saved-row-name';
|
||||||
|
name.textContent = entry.name;
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'saved-row-meta';
|
||||||
|
meta.textContent = metaLine(entry);
|
||||||
|
|
||||||
|
const stamp = document.createElement('span');
|
||||||
|
stamp.className = 'saved-row-stamp';
|
||||||
|
stamp.textContent = stampLine(entry);
|
||||||
|
|
||||||
|
text.appendChild(name);
|
||||||
|
text.appendChild(meta);
|
||||||
|
text.appendChild(stamp);
|
||||||
|
main.appendChild(count);
|
||||||
|
main.appendChild(text);
|
||||||
|
main.addEventListener('click', () => {
|
||||||
|
if (selecting) {
|
||||||
|
if (picked.has(entry.id)) picked.delete(entry.id);
|
||||||
|
else picked.add(entry.id);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
choose(entry.id);
|
||||||
|
});
|
||||||
|
row.appendChild(main);
|
||||||
|
|
||||||
|
// While picking, a tap means "this one too" rather than "open
|
||||||
|
// this", so the row says so: a tick where the count was, and no
|
||||||
|
// rename or remove to catch the thumb on the way past.
|
||||||
|
if (selecting) {
|
||||||
|
row.classList.add('is-selecting');
|
||||||
|
row.classList.toggle('is-picked', picked.has(entry.id));
|
||||||
|
count.classList.remove('is-quiet');
|
||||||
|
count.classList.add('saved-pick');
|
||||||
|
count.textContent = picked.has(entry.id) ? '✓' : '';
|
||||||
|
count.removeAttribute('title');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending && pending.id === entry.id && pending.mode === 'rename') {
|
||||||
|
const field = renameField(entry, row);
|
||||||
|
name.replaceWith(field);
|
||||||
|
main.disabled = true;
|
||||||
|
requestAnimationFrame(() => { field.focus(); field.select(); });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending && pending.id === entry.id && pending.mode === 'delete') {
|
||||||
|
row.classList.add('is-confirming');
|
||||||
|
row.appendChild(confirmStrip(entry));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = document.createElement('div');
|
||||||
|
tools.className = 'saved-row-tools';
|
||||||
|
const rename = toolButton('rename', `Rename ${entry.name}`, RENAME_ICON);
|
||||||
|
rename.addEventListener('click', () => { pending = { id: entry.id, mode: 'rename' }; render(); });
|
||||||
|
const remove = toolButton('delete', `Remove ${entry.name}`, DELETE_ICON);
|
||||||
|
remove.addEventListener('click', () => { pending = { id: entry.id, mode: 'delete' }; render(); });
|
||||||
|
tools.appendChild(rename);
|
||||||
|
tools.appendChild(remove);
|
||||||
|
row.appendChild(tools);
|
||||||
|
// What updateRow writes to, so it doesn't have to search the row
|
||||||
|
// again on every check.
|
||||||
|
row._parts = { count: count, name: name, meta: meta, stamp: stamp,
|
||||||
|
rename: rename, remove: remove };
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The same row, told what changed. A check writes a new count and a new
|
||||||
|
// "checked" time on every saved search it asks about; rebuilding the
|
||||||
|
// list for that would play the whole list's entry animation again,
|
||||||
|
// drop the reader's scroll position and take the pointer out of
|
||||||
|
// whatever it was over. A background check has no business doing any
|
||||||
|
// of that, so nothing is replaced -- the few strings that differ are
|
||||||
|
// written in place.
|
||||||
|
const updateRow = function(row, entry, index) {
|
||||||
|
const parts = row._parts;
|
||||||
|
if (!parts) return row;
|
||||||
|
row.classList.remove('is-new');
|
||||||
|
row.style.setProperty('--i', String(index));
|
||||||
|
row.classList.toggle('is-unreachable', !!entry.unreachable);
|
||||||
|
|
||||||
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||||
|
const countText = label || '·';
|
||||||
|
if (parts.count.textContent !== countText) parts.count.textContent = countText;
|
||||||
|
parts.count.classList.toggle('is-quiet', !label);
|
||||||
|
if (label) parts.count.title = `${entry.newCount} new since you last opened this`;
|
||||||
|
else parts.count.removeAttribute('title');
|
||||||
|
|
||||||
|
if (parts.name.textContent !== entry.name) {
|
||||||
|
parts.name.textContent = entry.name;
|
||||||
|
parts.rename.title = `Rename ${entry.name}`;
|
||||||
|
parts.rename.setAttribute('aria-label', `Rename ${entry.name}`);
|
||||||
|
parts.remove.title = `Remove ${entry.name}`;
|
||||||
|
parts.remove.setAttribute('aria-label', `Remove ${entry.name}`);
|
||||||
|
}
|
||||||
|
const meta = metaLine(entry);
|
||||||
|
if (parts.meta.textContent !== meta) parts.meta.textContent = meta;
|
||||||
|
const stamp = stampLine(entry);
|
||||||
|
if (parts.stamp.textContent !== stamp) parts.stamp.textContent = stamp;
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = function() {
|
||||||
|
const list = el('saved-list');
|
||||||
|
const empty = el('saved-empty');
|
||||||
|
const note = el('saved-head-note');
|
||||||
|
const add = el('saved-add-btn');
|
||||||
|
const check = el('saved-check-btn');
|
||||||
|
const read = el('saved-read-btn');
|
||||||
|
const select = el('saved-select-btn');
|
||||||
|
const mergeBtn = el('saved-merge-btn');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const entries = App.savedSearches.getAll();
|
||||||
|
const onScreen = new Map();
|
||||||
|
Array.from(list.children).forEach((row) => onScreen.set(row.dataset.id, row));
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const had = onScreen.get(entry.id);
|
||||||
|
onScreen.delete(entry.id);
|
||||||
|
// A row mid-rename or mid-confirm is a different row: it has
|
||||||
|
// its own field and buttons, so that one is built.
|
||||||
|
let row;
|
||||||
|
if (had && had.dataset.mode === modeOf(entry)) {
|
||||||
|
row = updateRow(had, entry, index);
|
||||||
|
} else {
|
||||||
|
row = buildRow(entry, index);
|
||||||
|
// A row that changed shape where it stood has not arrived
|
||||||
|
// either -- it is the same row, asking something.
|
||||||
|
if (had) row.classList.remove('is-new');
|
||||||
|
}
|
||||||
|
if (list.children[index] !== row) {
|
||||||
|
list.insertBefore(row, list.children[index] || null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onScreen.forEach((row) => row.remove());
|
||||||
|
if (empty) empty.hidden = entries.length > 0;
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
const total = App.savedSearches.totalNew();
|
||||||
|
note.textContent = !entries.length ?
|
||||||
|
'Keep a search once, come back to it in one tap.' :
|
||||||
|
(total ?
|
||||||
|
`${App.savedSearches.badgeLabel(total)} new since you last looked.` :
|
||||||
|
'Checked once a day. Nothing new right now.');
|
||||||
|
}
|
||||||
|
if (check) {
|
||||||
|
check.disabled = !entries.length || App.savedSearches.isChecking();
|
||||||
|
check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now';
|
||||||
|
}
|
||||||
|
if (read) {
|
||||||
|
const marking = App.savedSearches.isMarkingAllRead();
|
||||||
|
// Nothing to clear is not a button worth pressing.
|
||||||
|
read.disabled = marking || !App.savedSearches.totalNew();
|
||||||
|
read.textContent = marking ? 'Marking…' : 'Mark all read';
|
||||||
|
}
|
||||||
|
if (select) {
|
||||||
|
// There is nothing to merge with fewer than two of them.
|
||||||
|
select.hidden = entries.length < 2;
|
||||||
|
select.textContent = selecting ? 'Cancel' : 'Merge…';
|
||||||
|
}
|
||||||
|
if (mergeBtn) {
|
||||||
|
mergeBtn.hidden = !selecting;
|
||||||
|
mergeBtn.disabled = picked.size < 2;
|
||||||
|
mergeBtn.textContent = picked.size < 2
|
||||||
|
? 'Pick two or more to merge'
|
||||||
|
: `Merge ${picked.size} selected`;
|
||||||
|
}
|
||||||
|
if (add) {
|
||||||
|
// While picking, the merge is the only action that means
|
||||||
|
// anything down here.
|
||||||
|
add.hidden = selecting;
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
add.disabled = !!match || !App.savedSearches.current();
|
||||||
|
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopSelecting = function() {
|
||||||
|
selecting = false;
|
||||||
|
picked = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = function(id) {
|
||||||
|
close();
|
||||||
|
App.savedSearches.apply(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = function() {
|
||||||
|
const box = panel();
|
||||||
|
if (!box) return;
|
||||||
|
pending = null;
|
||||||
|
stopSelecting();
|
||||||
|
render();
|
||||||
|
box.classList.add('open');
|
||||||
|
box.setAttribute('aria-hidden', 'false');
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
// Opening the list is as good a moment as any to find out whether
|
||||||
|
// anything is due -- the daily check may have been missed by a tab
|
||||||
|
// that was never left open that long.
|
||||||
|
App.savedSearches.check();
|
||||||
|
const closeBtn = el('saved-close-btn');
|
||||||
|
if (closeBtn) requestAnimationFrame(() => closeBtn.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = function() {
|
||||||
|
const box = panel();
|
||||||
|
// Where the keyboard goes next: back to the button that opened
|
||||||
|
// this, not to the top of the page behind it.
|
||||||
|
const returnFocus = !!(box && box.contains(document.activeElement));
|
||||||
|
if (box) {
|
||||||
|
box.classList.remove('open');
|
||||||
|
box.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
pending = null;
|
||||||
|
stopSelecting();
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
if (returnFocus) trigger.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bookmark in the search bar: lit when what is on screen is
|
||||||
|
// already saved, and pressing it then takes it off the list again.
|
||||||
|
const toggleCurrent = function() {
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
if (match) {
|
||||||
|
App.savedSearches.remove(match.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = App.savedSearches.saveCurrent();
|
||||||
|
if (!saved) return;
|
||||||
|
const button = el('search-save-btn');
|
||||||
|
if (button) {
|
||||||
|
button.classList.remove('is-just-saved');
|
||||||
|
// Restarted rather than left to run: saving twice in a row
|
||||||
|
// should flash twice.
|
||||||
|
void button.offsetWidth;
|
||||||
|
button.classList.add('is-just-saved');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keeps every piece of the feature showing the same thing: the
|
||||||
|
// bookmark's state, the header's count, and the list if it is open.
|
||||||
|
const sync = function() {
|
||||||
|
const button = el('search-save-btn');
|
||||||
|
if (button) {
|
||||||
|
const match = App.savedSearches.findMatching();
|
||||||
|
const available = !!App.savedSearches.current();
|
||||||
|
button.classList.toggle('is-saved', !!match);
|
||||||
|
button.setAttribute('aria-pressed', match ? 'true' : 'false');
|
||||||
|
button.disabled = !available;
|
||||||
|
const title = match ? `Saved as “${match.name}” — tap to remove` : 'Save this search';
|
||||||
|
button.title = title;
|
||||||
|
button.setAttribute('aria-label', title);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = el('saved-searches-badge');
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (badge) {
|
||||||
|
const total = App.savedSearches.totalNew();
|
||||||
|
const label = App.savedSearches.badgeLabel(total);
|
||||||
|
badge.textContent = label;
|
||||||
|
badge.hidden = !label;
|
||||||
|
if (trigger) {
|
||||||
|
const count = App.savedSearches.getAll().length;
|
||||||
|
trigger.setAttribute('aria-label', label ?
|
||||||
|
`Saved searches, ${total > App.savedSearches.NEW_CAP ? 'more than ' + App.savedSearches.NEW_CAP : total} new` :
|
||||||
|
(count ? `Saved searches (${count})` : 'Saved searches'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rename in progress is the reader's text, not ours to redraw.
|
||||||
|
if (isOpen() && !(pending && pending.mode === 'rename')) render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const bind = function() {
|
||||||
|
if (bound) return;
|
||||||
|
const box = panel();
|
||||||
|
const trigger = el('saved-searches-btn');
|
||||||
|
if (!box || !trigger) return;
|
||||||
|
bound = true;
|
||||||
|
|
||||||
|
trigger.addEventListener('click', () => { if (isOpen()) close(); else open(); });
|
||||||
|
const closeBtn = el('saved-close-btn');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||||
|
box.addEventListener('click', (event) => { if (event.target === box) close(); });
|
||||||
|
|
||||||
|
const saveBtn = el('search-save-btn');
|
||||||
|
if (saveBtn) saveBtn.addEventListener('click', toggleCurrent);
|
||||||
|
|
||||||
|
const add = el('saved-add-btn');
|
||||||
|
if (add) {
|
||||||
|
add.addEventListener('click', () => {
|
||||||
|
const saved = App.savedSearches.saveCurrent();
|
||||||
|
if (saved) pending = { id: saved.id, mode: 'rename' };
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = el('saved-select-btn');
|
||||||
|
if (select) {
|
||||||
|
select.addEventListener('click', () => {
|
||||||
|
if (selecting) stopSelecting();
|
||||||
|
else { selecting = true; picked = new Set(); pending = null; }
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeBtn = el('saved-merge-btn');
|
||||||
|
if (mergeBtn) {
|
||||||
|
mergeBtn.addEventListener('click', () => {
|
||||||
|
// In the order they are listed, not the order they were
|
||||||
|
// ticked: the list is the reader's own arrangement.
|
||||||
|
const order = App.savedSearches.getAll()
|
||||||
|
.map((entry) => entry.id)
|
||||||
|
.filter((id) => picked.has(id));
|
||||||
|
const made = App.savedSearches.merge(order);
|
||||||
|
stopSelecting();
|
||||||
|
// Straight into renaming it: a merge's own name is the
|
||||||
|
// only thing about it nobody has said yet.
|
||||||
|
if (made) pending = { id: made.id, mode: 'rename' };
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = el('saved-check-btn');
|
||||||
|
if (check) {
|
||||||
|
check.addEventListener('click', () => {
|
||||||
|
App.savedSearches.check({ force: true });
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = el('saved-read-btn');
|
||||||
|
if (read) {
|
||||||
|
read.addEventListener('click', () => {
|
||||||
|
App.savedSearches.markAllRead();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && isOpen()) {
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
bind: bind,
|
||||||
|
sync: sync,
|
||||||
|
open: open,
|
||||||
|
close: close,
|
||||||
|
isOpen: isOpen,
|
||||||
|
toggleCurrent: toggleCurrent
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Channel picker
|
// Channel picker
|
||||||
//
|
//
|
||||||
@@ -643,8 +1382,36 @@ App.ui = App.ui || {};
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// Settings + menu rendering.
|
// Settings + menu rendering.
|
||||||
|
// A merge carries its members' own words, and one box cannot stand for
|
||||||
|
// several (see fetchMergedBatch, which does not read it). So rather than
|
||||||
|
// leave a live-looking box that changes nothing, it is turned off and says
|
||||||
|
// why. Switching to any single channel turns it back on.
|
||||||
|
let searchPlaceholder = null;
|
||||||
|
App.ui.syncSearchForSession = function(session) {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (!searchInput) return;
|
||||||
|
// Kept from the markup the first time through, so putting it back does
|
||||||
|
// not depend on remembering what it said.
|
||||||
|
if (searchPlaceholder === null) searchPlaceholder = searchInput.placeholder;
|
||||||
|
const merged = !!(session && session.channel && session.channel.isMerged);
|
||||||
|
searchInput.disabled = merged;
|
||||||
|
searchInput.classList.toggle('is-inert', merged);
|
||||||
|
if (merged) {
|
||||||
|
searchInput.value = '';
|
||||||
|
searchInput.placeholder = 'Each search in this merge keeps its own words';
|
||||||
|
} else {
|
||||||
|
searchInput.placeholder = searchPlaceholder;
|
||||||
|
}
|
||||||
|
const clearBtn = document.getElementById('search-clear-btn');
|
||||||
|
if (clearBtn && merged) {
|
||||||
|
clearBtn.classList.remove('is-visible');
|
||||||
|
clearBtn.disabled = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
App.ui.renderMenu = function() {
|
App.ui.renderMenu = function() {
|
||||||
const session = App.storage.getSession();
|
const session = App.storage.getSession();
|
||||||
|
App.ui.syncSearchForSession(session);
|
||||||
const serverEntries = App.storage.getServerEntries();
|
const serverEntries = App.storage.getServerEntries();
|
||||||
const sourceSelect = document.getElementById('source-select');
|
const sourceSelect = document.getElementById('source-select');
|
||||||
const filtersContainer = document.getElementById('filters-container');
|
const filtersContainer = document.getElementById('filters-container');
|
||||||
@@ -868,6 +1635,10 @@ App.ui = App.ui || {};
|
|||||||
App.videos.resetAndReload();
|
App.videos.resetAndReload();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source, channel and filters are all part of what a saved search is,
|
||||||
|
// so whenever the menu redraws, so does the bookmark.
|
||||||
|
if (App.ui.savedSearches) App.ui.savedSearches.sync();
|
||||||
};
|
};
|
||||||
|
|
||||||
App.ui.renderFilters = function(container, session) {
|
App.ui.renderFilters = function(container, session) {
|
||||||
@@ -1055,6 +1826,56 @@ App.ui = App.ui || {};
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Settings -> Captured Thumbnails. A favorite whose listed picture is gone
|
||||||
|
// gets a frame out of the video instead, kept for good so no later session
|
||||||
|
// pays for it again (see App.posters). Kept for good is also the problem:
|
||||||
|
// a picture taken from the wrong moment, or from a version of this that
|
||||||
|
// kept frames with nothing in them, has no other way out. So the reader
|
||||||
|
// can throw the lot away and have them taken again.
|
||||||
|
const posterCacheNote = function() {
|
||||||
|
if (!App.posters || typeof App.posters.stats !== 'function') return '';
|
||||||
|
const stats = App.posters.stats();
|
||||||
|
if (!stats.count) {
|
||||||
|
return 'No pictures taken from videos yet. A favorite gets one when ' +
|
||||||
|
'its own thumbnail is gone for good.';
|
||||||
|
}
|
||||||
|
const plural = stats.count === 1 ? 'picture' : 'pictures';
|
||||||
|
const kb = Math.max(1, Math.round(stats.bytes / 1024));
|
||||||
|
return `${stats.count} ${plural} kept, ${kb} KB. Throwing them away has ` +
|
||||||
|
'each one taken again from the video the next time its card is shown.';
|
||||||
|
};
|
||||||
|
|
||||||
|
App.ui.syncPosterCache = function() {
|
||||||
|
const status = document.getElementById('clear-posters-status');
|
||||||
|
if (status) status.textContent = posterCacheNote();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.ui.bindPosterCacheControls = function() {
|
||||||
|
const button = document.getElementById('clear-posters-btn');
|
||||||
|
if (!button) return;
|
||||||
|
App.ui.syncPosterCache();
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const before = App.posters.stats().count;
|
||||||
|
App.posters.clear();
|
||||||
|
// The cards on screen are still showing what was just thrown away,
|
||||||
|
// so they go back to the start of the thumbnail ladder -- which is
|
||||||
|
// where a fresh capture comes from.
|
||||||
|
if (typeof App.videos.refreshThumbnails === 'function') {
|
||||||
|
App.videos.refreshThumbnails();
|
||||||
|
}
|
||||||
|
if (App.favorites && typeof App.favorites.renderBar === 'function') {
|
||||||
|
App.favorites.renderBar();
|
||||||
|
}
|
||||||
|
const status = document.getElementById('clear-posters-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = before
|
||||||
|
? `Threw away ${before} ${before === 1 ? 'picture' : 'pictures'}. ` +
|
||||||
|
'They are being taken again.'
|
||||||
|
: posterCacheNote();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Favorites bar header: the sort order (which applies to the bar and the
|
// Favorites bar header: the sort order (which applies to the bar and the
|
||||||
// favorites grid alike) and the toggle into that grid.
|
// favorites grid alike) and the toggle into that grid.
|
||||||
App.ui.bindFavoritesControls = function() {
|
App.ui.bindFavoritesControls = function() {
|
||||||
@@ -1080,6 +1901,7 @@ App.ui = App.ui || {};
|
|||||||
|
|
||||||
App.ui.bindGlobalHandlers = function() {
|
App.ui.bindGlobalHandlers = function() {
|
||||||
App.ui.bindBackupImport();
|
App.ui.bindBackupImport();
|
||||||
|
App.ui.bindPosterCacheControls();
|
||||||
App.ui.bindFavoritesControls();
|
App.ui.bindFavoritesControls();
|
||||||
|
|
||||||
window.toggleDrawer = App.ui.toggleDrawer;
|
window.toggleDrawer = App.ui.toggleDrawer;
|
||||||
@@ -1125,7 +1947,12 @@ App.ui = App.ui || {};
|
|||||||
// Update the clear button immediately for snappy feedback, but
|
// Update the clear button immediately for snappy feedback, but
|
||||||
// debounce the actual reload so typing doesn't wipe the grid and
|
// debounce the actual reload so typing doesn't wipe the grid and
|
||||||
// fire a backend request on every keystroke.
|
// fire a backend request on every keystroke.
|
||||||
|
App.ui.searchHistory.bind();
|
||||||
|
App.ui.savedSearches.bind();
|
||||||
searchInput.addEventListener('input', updateClearVisibility);
|
searchInput.addEventListener('input', updateClearVisibility);
|
||||||
|
// The bookmark is about this search, so it follows the words as
|
||||||
|
// they are typed, not just the channel and filters.
|
||||||
|
searchInput.addEventListener('input', () => App.ui.savedSearches.sync());
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
searchDebounce = setTimeout(() => {
|
searchDebounce = setTimeout(() => {
|
||||||
|
|||||||
@@ -270,9 +270,24 @@ App.videos = App.videos || {};
|
|||||||
// being asked.
|
// being asked.
|
||||||
const posterAsked = new Map(); // page URL -> promise of a replacement, or ''
|
const posterAsked = new Map(); // page URL -> promise of a replacement, or ''
|
||||||
|
|
||||||
|
// Last of all, once the listed picture is gone and the page has no
|
||||||
|
// better one to offer: a frame out of the video itself. Only for something
|
||||||
|
// the reader kept -- a capture costs a resolve and a stretch of video,
|
||||||
|
// which is not worth spending on a card that scrolls past once -- and the
|
||||||
|
// picture is cached, so an item pays for it once and no later session pays
|
||||||
|
// at all. See App.posters.
|
||||||
|
const capturePoster = function(img, token) {
|
||||||
|
const video = img._thumbVideo;
|
||||||
|
if (!video || !App.posters || !App.favorites || !App.favorites.has(video)) return;
|
||||||
|
App.posters.capture(video).then((src) => {
|
||||||
|
if (!src || img.dataset.thumbToken !== token) return;
|
||||||
|
showThumbnail(img, src, token);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const repairThumbnail = function(img, token) {
|
const repairThumbnail = function(img, token) {
|
||||||
const page = img.dataset.thumbPage || '';
|
const page = img.dataset.thumbPage || '';
|
||||||
if (!page) return;
|
if (!page) { capturePoster(img, token); return; }
|
||||||
let pending = posterAsked.get(page);
|
let pending = posterAsked.get(page);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
|
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
|
||||||
@@ -282,7 +297,9 @@ App.videos = App.videos || {};
|
|||||||
posterAsked.set(page, pending);
|
posterAsked.set(page, pending);
|
||||||
}
|
}
|
||||||
pending.then((replacement) => {
|
pending.then((replacement) => {
|
||||||
if (!replacement || img.dataset.thumbToken !== token) return;
|
if (img.dataset.thumbToken !== token) return;
|
||||||
|
// The page had nothing either, so there is one thing left to try.
|
||||||
|
if (!replacement) { capturePoster(img, token); return; }
|
||||||
// No second repair for this mount: the ladder below must not come
|
// No second repair for this mount: the ladder below must not come
|
||||||
// back here and ask the same page for the same picture forever.
|
// back here and ask the same page for the same picture forever.
|
||||||
delete img.dataset.thumbPage;
|
delete img.dataset.thumbPage;
|
||||||
@@ -525,15 +542,32 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Puts every thumbnail on the page back to the start of the ladder. For the
|
||||||
|
// settings action that throws the captured pictures away (see
|
||||||
|
// App.posters.clear): a card that is already mounted would otherwise keep
|
||||||
|
// showing the picture that was just discarded until something scrolled it
|
||||||
|
// out of the window and back.
|
||||||
|
App.videos.refreshThumbnails = function() {
|
||||||
|
document.querySelectorAll('img[data-thumb-token]').forEach((img) => {
|
||||||
|
const video = img._thumbVideo;
|
||||||
|
if (!video) return;
|
||||||
|
App.videos.attachThumbnail(img, video.thumb || '',
|
||||||
|
img.dataset.thumbPage || App.videos.sourcePageUrl(video), video);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Points `img` at `url` by whichever route is known to work for its host,
|
// Points `img` at `url` by whichever route is known to work for its host,
|
||||||
// racing the two the first time that host is seen. `page` is optional: the
|
// racing the two the first time that host is seen. `page` is optional: the
|
||||||
// item's own page, asked for a replacement if the picture turns out to be
|
// item's own page, asked for a replacement if the picture turns out to be
|
||||||
// gone.
|
// gone.
|
||||||
App.videos.attachThumbnail = function(img, url, page) {
|
App.videos.attachThumbnail = function(img, url, page, video) {
|
||||||
// An address that isn't one is the same thing as no thumbnail: the card
|
// An address that isn't one is the same thing as no thumbnail: the card
|
||||||
// keeps its placeholder rather than chasing it. See usableThumbUrl.
|
// keeps its placeholder rather than chasing it. See usableThumbUrl.
|
||||||
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
|
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
|
// The item this picture is of, kept for the capture at the end of the
|
||||||
|
// ladder (see capturePoster).
|
||||||
|
img._thumbVideo = video || null;
|
||||||
// Held back until there is an image to caption -- see showThumbnail. An
|
// Held back until there is an image to caption -- see showThumbnail. An
|
||||||
// item with no thumbnail keeps an empty alt: the card's own title sits
|
// item with no thumbnail keeps an empty alt: the card's own title sits
|
||||||
// directly beneath the box, so there is nothing for it to add.
|
// directly beneath the box, so there is nothing for it to add.
|
||||||
@@ -541,7 +575,39 @@ App.videos = App.videos || {};
|
|||||||
img.dataset.alt = img.alt;
|
img.dataset.alt = img.alt;
|
||||||
img.alt = '';
|
img.alt = '';
|
||||||
}
|
}
|
||||||
if (!directUrl) return;
|
// Every attach is a new generation, so work started for a previous one
|
||||||
|
// stops being able to touch this element.
|
||||||
|
const token = String(++thumbSeq);
|
||||||
|
img.dataset.thumbToken = token;
|
||||||
|
if (page) img.dataset.thumbPage = page;
|
||||||
|
else delete img.dataset.thumbPage;
|
||||||
|
|
||||||
|
// A picture captured for this item earlier wins outright. The cache
|
||||||
|
// only ever holds one because every route to the listed picture had
|
||||||
|
// already failed, so going back to the wire for it would be a request
|
||||||
|
// that is known to fail, on every mount, for the rest of the item's
|
||||||
|
// life.
|
||||||
|
const captured = App.posters ? App.posters.cached(video) : '';
|
||||||
|
if (captured) {
|
||||||
|
showThumbnail(img, captured, token);
|
||||||
|
// A picture kept before captures were checked for having anything
|
||||||
|
// in them can be a frame of nothing -- a black box where the grey
|
||||||
|
// one used to be. Measured once, behind the picture that is already
|
||||||
|
// up, and replaced if that is what it turns out to be.
|
||||||
|
if (typeof App.posters.recheck === 'function') {
|
||||||
|
App.posters.recheck(video).then((fresh) => {
|
||||||
|
if (fresh && img.dataset.thumbToken === token) showThumbnail(img, fresh, token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!directUrl) {
|
||||||
|
// Nothing listed to fail, so there is nothing to walk a ladder
|
||||||
|
// down: a frame out of the video is the only picture this item is
|
||||||
|
// ever going to have.
|
||||||
|
capturePoster(img, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A cross-origin Referer is what most hotlink protection keys on, and an
|
// A cross-origin Referer is what most hotlink protection keys on, and an
|
||||||
// image needs none. Sending none is what lets the direct route work at
|
// image needs none. Sending none is what lets the direct route work at
|
||||||
// all on a fair number of providers -- and the direct route is the one
|
// all on a fair number of providers -- and the direct route is the one
|
||||||
@@ -551,12 +617,6 @@ App.videos = App.videos || {};
|
|||||||
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
||||||
const host = imageHostOf(directUrl);
|
const host = imageHostOf(directUrl);
|
||||||
const route = imageRoutes.get(host);
|
const route = imageRoutes.get(host);
|
||||||
// Every attach is a new generation, so work started for a previous one
|
|
||||||
// stops being able to touch this element.
|
|
||||||
const token = String(++thumbSeq);
|
|
||||||
img.dataset.thumbToken = token;
|
|
||||||
if (page) img.dataset.thumbPage = page;
|
|
||||||
else delete img.dataset.thumbPage;
|
|
||||||
|
|
||||||
if (route === IMAGE_PROXY) {
|
if (route === IMAGE_PROXY) {
|
||||||
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
|
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
|
||||||
@@ -590,6 +650,7 @@ App.videos = App.videos || {};
|
|||||||
img.dataset.thumbToken = String(++thumbSeq);
|
img.dataset.thumbToken = String(++thumbSeq);
|
||||||
delete img.dataset.alt;
|
delete img.dataset.alt;
|
||||||
delete img.dataset.thumbPage;
|
delete img.dataset.thumbPage;
|
||||||
|
img._thumbVideo = null;
|
||||||
detachRetry(img);
|
detachRetry(img);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -598,6 +659,31 @@ App.videos = App.videos || {};
|
|||||||
// dominating a single interleaved batch.
|
// dominating a single interleaved batch.
|
||||||
const GROUP_CHANNEL_PAGE_SIZE = 4;
|
const GROUP_CHANNEL_PAGE_SIZE = 4;
|
||||||
|
|
||||||
|
// A page number is claimed before its request goes out, not after it comes
|
||||||
|
// back, which is what lets several pages be on the wire at once. A request
|
||||||
|
// that fails puts its number back in the hat rather than leaving a hole in
|
||||||
|
// the listing. `cursor` is the group's per-channel cursor, or the session-
|
||||||
|
// wide one below for a plain channel.
|
||||||
|
const claimPage = function(cursor) {
|
||||||
|
if (cursor.retries && cursor.retries.length) return cursor.retries.shift();
|
||||||
|
return cursor.page++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnPage = function(cursor, page) {
|
||||||
|
cursor.retries = cursor.retries || [];
|
||||||
|
cursor.retries.push(page);
|
||||||
|
cursor.retries.sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The plain-channel cursor. state.currentPage is the number itself, so
|
||||||
|
// resetGrid keeps working by setting it back to 1; the retry list is
|
||||||
|
// cleared alongside it in resetPrefetch.
|
||||||
|
const channelCursor = {
|
||||||
|
get page() { return state.currentPage; },
|
||||||
|
set page(next) { state.currentPage = next; },
|
||||||
|
retries: []
|
||||||
|
};
|
||||||
|
|
||||||
// Fetches one page from every channel in a group and zips the results
|
// Fetches one page from every channel in a group and zips the results
|
||||||
// together round-robin so the feed alternates between sources instead of
|
// together round-robin so the feed alternates between sources instead of
|
||||||
// running through one channel's videos before moving to the next.
|
// running through one channel's videos before moving to the next.
|
||||||
@@ -631,23 +717,28 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(active.map(async (cursor) => {
|
const results = await Promise.all(active.map(async (cursor) => {
|
||||||
const response = await fetch('/api/videos', {
|
const page = claimPage(cursor);
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch('/api/videos', {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
channel: cursor.id,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
query: query || "",
|
body: JSON.stringify({
|
||||||
page: cursor.page,
|
channel: cursor.id,
|
||||||
perPage: GROUP_CHANNEL_PAGE_SIZE,
|
query: query || "",
|
||||||
server: session.server
|
page: page,
|
||||||
}),
|
perPage: GROUP_CHANNEL_PAGE_SIZE,
|
||||||
signal: signal
|
server: session.server
|
||||||
});
|
}),
|
||||||
const data = await response.json();
|
signal: signal
|
||||||
const items = data && Array.isArray(data.items) ? data.items : [];
|
});
|
||||||
cursor.page++;
|
const data = await response.json();
|
||||||
cursor.hasNextPage = items.length > 0 && (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true);
|
const items = data && Array.isArray(data.items) ? data.items : [];
|
||||||
return items;
|
cursor.hasNextPage = items.length > 0 && (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true);
|
||||||
|
return items;
|
||||||
|
} catch (err) {
|
||||||
|
returnPage(cursor, page);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const interleaved = [];
|
const interleaved = [];
|
||||||
@@ -664,14 +755,103 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetches one page from every search in a merge and zips them together,
|
||||||
|
// the way a group does its channels -- except that a merge's members share
|
||||||
|
// nothing. Each one carries its own server, channel, filters and words, so
|
||||||
|
// each gets its own request built from itself rather than from the session.
|
||||||
|
//
|
||||||
|
// The search box is deliberately not consulted: a merge is several
|
||||||
|
// searches, each with its own words, and one box cannot speak for all of
|
||||||
|
// them (see applyMerged).
|
||||||
|
const fetchMergedBatch = async function(session, signal) {
|
||||||
|
const merged = session.channel;
|
||||||
|
const all = Array.isArray(merged.members) ? merged.members : [];
|
||||||
|
|
||||||
|
// The per-merge "Searches" multi-select, so a member can be switched
|
||||||
|
// off while browsing. Falls back to all of them when nothing is
|
||||||
|
// selected.
|
||||||
|
const selected = session.options ? session.options[App.session.MERGED_MEMBERS_OPTION_ID] : null;
|
||||||
|
const enabledIds = Array.isArray(selected) && selected.length > 0
|
||||||
|
? selected.map((opt) => opt.id)
|
||||||
|
: all.map((member) => member.memberId);
|
||||||
|
const members = all.filter((member) => enabledIds.includes(member.memberId));
|
||||||
|
const signature = members.map((member) => member.memberId).join(',');
|
||||||
|
|
||||||
|
if (!state.mergedCursors || state.mergedCursors.mergeId !== merged.id ||
|
||||||
|
state.mergedCursors.signature !== signature) {
|
||||||
|
state.mergedCursors = {
|
||||||
|
mergeId: merged.id,
|
||||||
|
signature: signature,
|
||||||
|
members: members.map((member) => ({ member: member, page: 1, hasNextPage: true }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = state.mergedCursors.members.filter((cursor) => cursor.hasNextPage);
|
||||||
|
if (active.length === 0) {
|
||||||
|
return { items: [], hasNextPage: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(active.map(async (cursor) => {
|
||||||
|
const member = cursor.member;
|
||||||
|
const page = claimPage(cursor);
|
||||||
|
const body = {
|
||||||
|
channel: member.channelId,
|
||||||
|
query: member.query || '',
|
||||||
|
page: page,
|
||||||
|
perPage: GROUP_CHANNEL_PAGE_SIZE,
|
||||||
|
server: member.server
|
||||||
|
};
|
||||||
|
Object.entries(member.options || {}).forEach(([key, value]) => {
|
||||||
|
body[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/videos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: signal
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const items = data && Array.isArray(data.items) ? data.items : [];
|
||||||
|
cursor.hasNextPage = items.length > 0 &&
|
||||||
|
(data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true);
|
||||||
|
return items;
|
||||||
|
} catch (err) {
|
||||||
|
returnPage(cursor, page);
|
||||||
|
// One source failing is not the merge failing: the others
|
||||||
|
// still have pages, and a merge that drops to one member is
|
||||||
|
// still worth reading. An aborted load is the exception --
|
||||||
|
// that is the grid being reset under us, and it has to
|
||||||
|
// propagate or the reset races the batch it cancelled.
|
||||||
|
if (err && err.name === 'AbortError') throw err;
|
||||||
|
cursor.hasNextPage = false;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const interleaved = [];
|
||||||
|
const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0);
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
results.forEach((items) => {
|
||||||
|
if (items[i]) interleaved.push(items[i]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: interleaved,
|
||||||
|
hasNextPage: state.mergedCursors.members.some((cursor) => cursor.hasNextPage)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const fetchChannelBatch = async function(session, signal) {
|
const fetchChannelBatch = async function(session, signal) {
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const query = searchInput ? searchInput.value : "";
|
const query = searchInput ? searchInput.value : "";
|
||||||
|
|
||||||
|
const page = claimPage(channelCursor);
|
||||||
const body = {
|
const body = {
|
||||||
channel: session.channel.id,
|
channel: session.channel.id,
|
||||||
query: query || "",
|
query: query || "",
|
||||||
page: state.currentPage,
|
page: page,
|
||||||
perPage: state.perPage,
|
perPage: state.perPage,
|
||||||
server: session.server
|
server: session.server
|
||||||
};
|
};
|
||||||
@@ -684,14 +864,19 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('/api/videos', {
|
let videos;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch('/api/videos', {
|
||||||
body: JSON.stringify(body),
|
method: 'POST',
|
||||||
signal: signal
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
body: JSON.stringify(body),
|
||||||
const videos = await response.json();
|
signal: signal
|
||||||
state.currentPage++;
|
});
|
||||||
|
videos = await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
returnPage(channelCursor, page);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
items: videos && Array.isArray(videos.items) ? videos.items : [],
|
items: videos && Array.isArray(videos.items) ? videos.items : [],
|
||||||
hasNextPage: videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true
|
hasNextPage: videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true
|
||||||
@@ -699,6 +884,7 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchNextBatch = function(session, signal) {
|
const fetchNextBatch = function(session, signal) {
|
||||||
|
if (session.channel.isMerged) return fetchMergedBatch(session, signal);
|
||||||
return session.channel.isGroup
|
return session.channel.isGroup
|
||||||
? fetchGroupBatch(session, signal)
|
? fetchGroupBatch(session, signal)
|
||||||
: fetchChannelBatch(session, signal);
|
: fetchChannelBatch(session, signal);
|
||||||
@@ -712,11 +898,28 @@ App.videos = App.videos || {};
|
|||||||
// reader reaches the second-to-last row (see the virtualizer's update()).
|
// reader reaches the second-to-last row (see the virtualizer's update()).
|
||||||
// So the cards that come into view are complete on arrival instead of
|
// So the cards that come into view are complete on arrival instead of
|
||||||
// shimmering their way in while the request is still on the wire.
|
// shimmering their way in while the request is still on the wire.
|
||||||
|
//
|
||||||
|
// How far ahead is `prefetch.depth`: one page for the grid, more when the
|
||||||
|
// reels feed is running a split and gets through a page in two swipes.
|
||||||
|
// Pages are fetched at once and given to the reader in page order.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
const prefetch = {
|
const prefetch = {
|
||||||
batch: null, // fetched + warmed, waiting for the reader
|
ready: [], // fetched pages waiting for the reader, in page order
|
||||||
inFlight: null, // promise of the fetch currently running
|
inFlight: [], // pages on the wire, in the order their numbers were claimed
|
||||||
controller: null
|
depth: 1, // how many pages to keep fetched or in flight
|
||||||
|
exhausted: false // a page came back saying it was the last one
|
||||||
|
};
|
||||||
|
|
||||||
|
// How deep to run ahead. One page is right for the grid, which reveals a
|
||||||
|
// page at a time. The reels feed asks for more: a step of N panels eats N
|
||||||
|
// videos per swipe, so a page is a couple of swipes and fetching them one
|
||||||
|
// after another leaves the feed waiting on a round trip it could have made
|
||||||
|
// earlier. See prefetchIfNeeded in feed.js.
|
||||||
|
App.videos.setPrefetchDepth = function(depth) {
|
||||||
|
const next = Math.max(1, Math.round(depth) || 1);
|
||||||
|
if (next === prefetch.depth) return;
|
||||||
|
prefetch.depth = next;
|
||||||
|
if (next > 1) App.videos.prefetchNextBatch();
|
||||||
};
|
};
|
||||||
|
|
||||||
// One slow thumbnail must not hold a whole page back.
|
// One slow thumbnail must not hold a whole page back.
|
||||||
@@ -768,29 +971,54 @@ App.videos = App.videos || {};
|
|||||||
App.videos.prefetchNextBatch(); // stay one page ahead again
|
App.videos.prefetchNextBatch(); // stay one page ahead again
|
||||||
};
|
};
|
||||||
|
|
||||||
App.videos.prefetchNextBatch = function() {
|
// Pages finish out of order -- page 3 can beat page 2 back -- so a finished
|
||||||
if (prefetch.batch || prefetch.inFlight) return Promise.resolve();
|
// page waits its turn here. Only a run of finished pages from the front
|
||||||
if (!state.hasNextPage) return Promise.resolve();
|
// moves across, which keeps what the reader is given in page order.
|
||||||
const session = App.storage.getSession();
|
const drainFinished = function() {
|
||||||
if (!session || !session.channel) return Promise.resolve();
|
while (prefetch.inFlight.length && prefetch.inFlight[0].done) {
|
||||||
|
const entry = prefetch.inFlight.shift();
|
||||||
|
if (entry.batch) prefetch.ready.push(entry.batch);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
prefetch.controller = new AbortController();
|
const startPrefetch = function(session) {
|
||||||
const signal = prefetch.controller.signal;
|
const controller = new AbortController();
|
||||||
prefetch.inFlight = (async () => {
|
const entry = { controller: controller, batch: null, done: false };
|
||||||
|
prefetch.inFlight.push(entry);
|
||||||
|
entry.promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const batch = await fetchNextBatch(session, signal);
|
const batch = await fetchNextBatch(session, controller.signal);
|
||||||
await warmThumbnails(batch.items);
|
if (!controller.signal.aborted) entry.batch = batch;
|
||||||
if (!signal.aborted) prefetch.batch = batch;
|
// Running several pages ahead means learning where the end is
|
||||||
|
// from a page the reader hasn't been given yet; without this,
|
||||||
|
// depth more requests would be claimed past it.
|
||||||
|
if (batch && batch.hasNextPage === false) prefetch.exhausted = true;
|
||||||
|
// Warming decodes the thumbnails so cards are complete when
|
||||||
|
// they come into view. It runs alongside the pages behind it
|
||||||
|
// rather than in front of them: a page held back for a slow
|
||||||
|
// image is a page the feed is waiting on.
|
||||||
|
warmThumbnails(batch.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== 'AbortError') {
|
if (err.name !== 'AbortError') {
|
||||||
console.error("Failed to prefetch videos:", err);
|
console.error("Failed to prefetch videos:", err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
prefetch.inFlight = null;
|
entry.done = true;
|
||||||
prefetch.controller = null;
|
drainFinished();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return prefetch.inFlight;
|
return entry.promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
App.videos.prefetchNextBatch = function() {
|
||||||
|
if (!state.hasNextPage || prefetch.exhausted) return Promise.resolve();
|
||||||
|
const session = App.storage.getSession();
|
||||||
|
if (!session || !session.channel) return Promise.resolve();
|
||||||
|
|
||||||
|
while (prefetch.ready.length + prefetch.inFlight.length < prefetch.depth) {
|
||||||
|
startPrefetch(session);
|
||||||
|
}
|
||||||
|
return prefetch.inFlight.length ? prefetch.inFlight[0].promise : Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Throws away a video's resolved formats and asks the server again. Media
|
// Throws away a video's resolved formats and asks the server again. Media
|
||||||
@@ -808,20 +1036,20 @@ App.videos = App.videos || {};
|
|||||||
|
|
||||||
// Called by the virtualizer once the reader is within two rows of the end.
|
// Called by the virtualizer once the reader is within two rows of the end.
|
||||||
App.videos.releasePrefetched = function() {
|
App.videos.releasePrefetched = function() {
|
||||||
if (!prefetch.batch || state.isLoading) return false;
|
if (!prefetch.ready.length || state.isLoading) return false;
|
||||||
const batch = prefetch.batch;
|
commitBatch(prefetch.ready.shift());
|
||||||
prefetch.batch = null;
|
|
||||||
commitBatch(batch);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drops anything in flight or held, for when the result set changes out
|
// Drops anything in flight or held, for when the result set changes out
|
||||||
// from under it (new search, channel, or filters).
|
// from under it (new search, channel, or filters).
|
||||||
App.videos.resetPrefetch = function() {
|
App.videos.resetPrefetch = function() {
|
||||||
if (prefetch.controller) prefetch.controller.abort();
|
prefetch.inFlight.forEach((entry) => entry.controller.abort());
|
||||||
prefetch.batch = null;
|
prefetch.inFlight = [];
|
||||||
prefetch.inFlight = null;
|
prefetch.ready = [];
|
||||||
prefetch.controller = null;
|
prefetch.exhausted = false;
|
||||||
|
// Page numbers claimed against the old result set mean nothing now.
|
||||||
|
channelCursor.retries = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Is the reader actually out of content? True when nothing is rendered yet,
|
// Is the reader actually out of content? True when nothing is rendered yet,
|
||||||
@@ -844,6 +1072,22 @@ App.videos = App.videos || {};
|
|||||||
// until the tab stops responding.
|
// until the tab stops responding.
|
||||||
let loadRunning = false;
|
let loadRunning = false;
|
||||||
|
|
||||||
|
// Stops whatever load is running and releases the guard in the same breath,
|
||||||
|
// so the caller can start the next one straight away. Without this a search
|
||||||
|
// typed while the box's own last search was still out was simply dropped --
|
||||||
|
// loadVideos saw a load running and returned -- and the reader was left
|
||||||
|
// looking at results for what they had typed a moment before. The aborted
|
||||||
|
// call unwinds on its own: its fetch rejects, it commits nothing, and its
|
||||||
|
// `finally` clears these again.
|
||||||
|
const abortLoad = function() {
|
||||||
|
if (state.currentLoadController) {
|
||||||
|
state.currentLoadController.abort();
|
||||||
|
state.currentLoadController = null;
|
||||||
|
}
|
||||||
|
loadRunning = false;
|
||||||
|
state.isLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
// Renders the next page: the prefetched one when it's ready (the common
|
// Renders the next page: the prefetched one when it's ready (the common
|
||||||
// case), otherwise fetched here and rendered as it lands. `opts.force` is for
|
// case), otherwise fetched here and rendered as it lands. `opts.force` is for
|
||||||
// callers that know they need content and can't be judged by where the grid
|
// callers that know they need content and can't be judged by where the grid
|
||||||
@@ -863,16 +1107,16 @@ App.videos = App.videos || {};
|
|||||||
loadRunning = true;
|
loadRunning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (prefetch.batch || prefetch.inFlight) {
|
if (prefetch.ready.length || prefetch.inFlight.length) {
|
||||||
// A page is already here or on its way; revealing it before the
|
// A page is already here or on its way; revealing it before the
|
||||||
// reader gets near the end would defeat the point of holding it.
|
// reader gets near the end would defeat the point of holding it.
|
||||||
if (!force && !needsContentNow()) return;
|
if (!force && !needsContentNow()) return;
|
||||||
if (App.videos.releasePrefetched()) return;
|
if (App.videos.releasePrefetched()) return;
|
||||||
if (prefetch.inFlight) {
|
if (prefetch.inFlight.length) {
|
||||||
// Wait for it rather than asking for the same page twice --
|
// Wait for the oldest rather than asking for the same page
|
||||||
// the fetch advances the page cursor, so a second request
|
// twice -- its number is already claimed, so a request from
|
||||||
// here would skip a page entirely.
|
// here would skip a page entirely.
|
||||||
await prefetch.inFlight;
|
await prefetch.inFlight[0].promise;
|
||||||
if (App.videos.releasePrefetched()) return;
|
if (App.videos.releasePrefetched()) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1032,7 +1276,7 @@ App.videos = App.videos || {};
|
|||||||
// the same frame, so loading a thumbnail for it -- let alone racing one
|
// the same frame, so loading a thumbnail for it -- let alone racing one
|
||||||
// -- would be pure waste.
|
// -- would be pure waste.
|
||||||
if (!(options && options.skipThumbnail)) {
|
if (!(options && options.skipThumbnail)) {
|
||||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v));
|
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||||
}
|
}
|
||||||
return card;
|
return card;
|
||||||
};
|
};
|
||||||
@@ -1228,20 +1472,67 @@ App.videos = App.videos || {};
|
|||||||
return (best || cards[0]).dataset.videoId || null;
|
return (best || cards[0]).dataset.videoId || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Puts a query in the box without searching for it. The clear button is
|
||||||
|
// kept in sync here rather than by re-dispatching an `input` event, which
|
||||||
|
// would re-trigger the debounced reload listener behind it.
|
||||||
|
App.videos.setSearchBox = function(value) {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (!searchInput) return;
|
||||||
|
if (searchInput.value !== value) searchInput.value = value;
|
||||||
|
const clearBtn = document.getElementById('search-clear-btn');
|
||||||
|
if (clearBtn) {
|
||||||
|
const hasValue = searchInput.value.trim().length > 0;
|
||||||
|
clearBtn.classList.toggle('is-visible', hasValue);
|
||||||
|
clearBtn.disabled = !hasValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Steps out of a merge onto one real channel: its first member whose
|
||||||
|
// source is still set up. Returns false when none of them is, in which
|
||||||
|
// case the merge is left alone -- there is nothing better to show.
|
||||||
|
App.videos.leaveMerge = function() {
|
||||||
|
const session = App.storage.getSession();
|
||||||
|
if (!session || !session.channel || !session.channel.isMerged) return false;
|
||||||
|
const entries = App.storage.getServerEntries();
|
||||||
|
for (const member of (session.channel.members || [])) {
|
||||||
|
const serverEntry = entries.find((candidate) => candidate.url === member.server);
|
||||||
|
const data = serverEntry ? serverEntry.data : null;
|
||||||
|
const channel = data ? App.session.resolveChannelById(data, member.channelId) : null;
|
||||||
|
if (!channel) continue;
|
||||||
|
const next = {
|
||||||
|
server: member.server,
|
||||||
|
channel: channel,
|
||||||
|
options: App.session.hydrateOptions(channel, member.options || {})
|
||||||
|
};
|
||||||
|
App.storage.setSession(next);
|
||||||
|
App.session.savePreference(next);
|
||||||
|
if (App.ui && typeof App.ui.renderMenu === 'function') App.ui.renderMenu();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
App.videos.handleSearch = function(value) {
|
App.videos.handleSearch = function(value) {
|
||||||
|
// Searching while a merge is open leaves it. A merge is held together
|
||||||
|
// by its members' own words (see fetchMergedBatch, which does not read
|
||||||
|
// the box), so there is nowhere in it to put new ones -- and a tag or
|
||||||
|
// an uploader tapped from a card would otherwise reload the same
|
||||||
|
// videos and look like nothing happened. The source and channel it
|
||||||
|
// lands on are the merge's first member, which is also the one the
|
||||||
|
// session was already pointed at.
|
||||||
|
const open = App.storage.getSession();
|
||||||
|
if (open && open.channel && open.channel.isMerged && typeof value === 'string' && value.trim()) {
|
||||||
|
App.videos.leaveMerge();
|
||||||
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const searchInput = document.getElementById('search-input');
|
App.videos.setSearchBox(value);
|
||||||
if (searchInput && searchInput.value !== value) {
|
}
|
||||||
searchInput.value = value;
|
// Every search worth running is a search worth remembering: this is
|
||||||
}
|
// the one place they all come through -- the box, a tag, the command
|
||||||
// Keep the clear button in sync without re-dispatching an `input`
|
// palette. Storage decides what is worth keeping (see rememberSearch).
|
||||||
// event (which would re-trigger the debounced reload listener).
|
const searched = document.getElementById('search-input');
|
||||||
const clearBtn = document.getElementById('search-clear-btn');
|
if (App.storage && typeof App.storage.rememberSearch === 'function') {
|
||||||
if (searchInput && clearBtn) {
|
App.storage.rememberSearch(searched ? searched.value : value);
|
||||||
const hasValue = searchInput.value.trim().length > 0;
|
|
||||||
clearBtn.classList.toggle('is-visible', hasValue);
|
|
||||||
clearBtn.disabled = !hasValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// A search is a new result set, so it leaves the favorites grid.
|
// A search is a new result set, so it leaves the favorites grid.
|
||||||
if (App.favoritesView && App.favoritesView.isActive()) {
|
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||||
@@ -1256,11 +1547,13 @@ App.videos = App.videos || {};
|
|||||||
App.videos.resetGrid = function() {
|
App.videos.resetGrid = function() {
|
||||||
// The held/in-flight page belongs to the old result set.
|
// The held/in-flight page belongs to the old result set.
|
||||||
App.videos.resetPrefetch();
|
App.videos.resetPrefetch();
|
||||||
|
abortLoad();
|
||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
state.hasNextPage = true;
|
state.hasNextPage = true;
|
||||||
state.renderedVideoIds.clear();
|
state.renderedVideoIds.clear();
|
||||||
state.loadedVideos = [];
|
state.loadedVideos = [];
|
||||||
state.groupCursors = null;
|
state.groupCursors = null;
|
||||||
|
state.mergedCursors = null;
|
||||||
App.virtualGrid.reset();
|
App.virtualGrid.reset();
|
||||||
if (App.feed && typeof App.feed.reset === 'function') {
|
if (App.feed && typeof App.feed.reset === 'function') {
|
||||||
App.feed.reset();
|
App.feed.reset();
|
||||||
@@ -1718,7 +2011,7 @@ App.videos = App.videos || {};
|
|||||||
if (!card || !v) return;
|
if (!card || !v) return;
|
||||||
unfilled.delete(i);
|
unfilled.delete(i);
|
||||||
const img = cardRefs(card).img;
|
const img = cardRefs(card).img;
|
||||||
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v));
|
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v), v);
|
||||||
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
||||||
// its true aspect ratio, clear the shimmer, then correct the height.
|
// its true aspect ratio, clear the shimmer, then correct the height.
|
||||||
if (img) {
|
if (img) {
|
||||||
@@ -2288,12 +2581,48 @@ App.videos = App.videos || {};
|
|||||||
// same for every media URL served by a given host: we probe (and cache)
|
// same for every media URL served by a given host: we probe (and cache)
|
||||||
// once per host and let the player skip the proxy for any URL on a host
|
// once per host and let the player skip the proxy for any URL on a host
|
||||||
// that's been proven.
|
// that's been proven.
|
||||||
|
//
|
||||||
|
// Readable bytes alone don't settle it. Not every hotlink guard answers 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 it reads as a win and plays as a different video. The probe
|
||||||
|
// therefore also checks it was handed *this* resource, by asking the proxy
|
||||||
|
// -- which carries the Referer the origin wants -- how long the media is.
|
||||||
|
// A direct route reporting some other length is a substitution, not a
|
||||||
|
// shortcut, and belongs on the proxy.
|
||||||
const DIRECT_PROBE_TIMEOUT_MS = 8000;
|
const DIRECT_PROBE_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
const directHostOf = (url) => {
|
const directHostOf = (url) => {
|
||||||
try { return new URL(url).host; } catch (err) { return ''; }
|
try { return new URL(url).host; } catch (err) { return ''; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The total size of the media a response describes, or 0 when it won't say.
|
||||||
|
// An un-ranged GET states it in Content-Length, which CORS exposes to
|
||||||
|
// script without the origin having to opt in; a ranged one puts it after
|
||||||
|
// the slash in Content-Range.
|
||||||
|
const totalLengthOf = function(res) {
|
||||||
|
const match = /\/\s*(\d+)\s*$/.exec(res.headers.get('Content-Range') || '');
|
||||||
|
if (match) return parseInt(match[1], 10) || 0;
|
||||||
|
return parseInt(res.headers.get('Content-Length') || '0', 10) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// How long the proxied route says this media is, or 0 if it won't say. The
|
||||||
|
// proxy answers a HEAD with the whole resource's length (see stream_video's
|
||||||
|
// head_probe) and fetches with the headers the extractor attached, so this
|
||||||
|
// is the length of the media the viewer is actually meant to get.
|
||||||
|
const proxiedLength = async function(resolved, signal) {
|
||||||
|
try {
|
||||||
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||||
|
if (!streamUrl) return 0;
|
||||||
|
const res = await fetch(streamUrl, { method: 'HEAD', signal: signal });
|
||||||
|
if (!(res.ok || res.status === 206)) return 0;
|
||||||
|
return totalLengthOf(res);
|
||||||
|
} catch (err) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// host -> true (proven directly playable) | false (proven not). Absent
|
// host -> true (proven directly playable) | false (proven not). Absent
|
||||||
// means unknown/unprobed, in which case the proxy is used.
|
// means unknown/unprobed, in which case the proxy is used.
|
||||||
App.videos._directStatus = new Map();
|
App.videos._directStatus = new Map();
|
||||||
@@ -2303,7 +2632,12 @@ App.videos = App.videos || {};
|
|||||||
return App.videos._directStatus.get(directHostOf(url)) === true;
|
return App.videos._directStatus.get(directHostOf(url)) === true;
|
||||||
};
|
};
|
||||||
|
|
||||||
App.videos.probeDirect = function(url) {
|
// Takes a resolved source (as resolveStreamSources builds them) so the
|
||||||
|
// proxied cross-check can be addressed with the same headers playback uses.
|
||||||
|
// A bare URL string still works; it just can't be cross-checked as closely.
|
||||||
|
App.videos.probeDirect = function(source) {
|
||||||
|
const resolved = (typeof source === 'string') ? { url: source } : (source || {});
|
||||||
|
const url = resolved.url;
|
||||||
if (!url) return Promise.resolve(false);
|
if (!url) return Promise.resolve(false);
|
||||||
const host = directHostOf(url);
|
const host = directHostOf(url);
|
||||||
if (!host) return Promise.resolve(false);
|
if (!host) return Promise.resolve(false);
|
||||||
@@ -2315,7 +2649,17 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), DIRECT_PROBE_TIMEOUT_MS);
|
// The cross-check gets its own controller: the direct fetch aborts
|
||||||
|
// as soon as it has its sample, and that must not cancel a HEAD the
|
||||||
|
// comparison still needs. One timer bounds them both.
|
||||||
|
const headController = new AbortController();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
headController.abort();
|
||||||
|
}, DIRECT_PROBE_TIMEOUT_MS);
|
||||||
|
// Asked alongside the direct fetch rather than after it: the probe
|
||||||
|
// runs beside live playback and shouldn't cost two serial trips.
|
||||||
|
const expectedLength = proxiedLength(resolved, headController.signal);
|
||||||
let ok = false;
|
let ok = false;
|
||||||
let detail = '';
|
let detail = '';
|
||||||
try {
|
try {
|
||||||
@@ -2348,6 +2692,18 @@ App.videos = App.videos || {};
|
|||||||
ok = true;
|
ok = true;
|
||||||
detail = `HTTP ${res.status}, headers only`;
|
detail = `HTTP ${res.status}, headers only`;
|
||||||
}
|
}
|
||||||
|
if (ok) {
|
||||||
|
// The same media, or a stand-in? Only decided when both
|
||||||
|
// sides state a length: a chunked response or a playlist
|
||||||
|
// states none, and an unknown length is no evidence of
|
||||||
|
// substitution.
|
||||||
|
const direct = totalLengthOf(res);
|
||||||
|
const expected = await expectedLength;
|
||||||
|
if (direct && expected && direct !== expected) {
|
||||||
|
ok = false;
|
||||||
|
detail += `, ${direct} bytes but proxied is ${expected} -- substituted`;
|
||||||
|
}
|
||||||
|
}
|
||||||
controller.abort();
|
controller.abort();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A CORS refusal lands here as a TypeError with no status --
|
// A CORS refusal lands here as a TypeError with no status --
|
||||||
@@ -2357,6 +2713,10 @@ App.videos = App.videos || {};
|
|||||||
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'blocked (CORS)';
|
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'blocked (CORS)';
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
// A refused direct fetch never waits on the comparison, so drop
|
||||||
|
// the HEAD rather than leaving it to run for an answer nobody
|
||||||
|
// will read. Harmless once it has already been awaited.
|
||||||
|
headController.abort();
|
||||||
}
|
}
|
||||||
App.videos._directStatus.set(host, ok);
|
App.videos._directStatus.set(host, ok);
|
||||||
directPending.delete(host);
|
directPending.delete(host);
|
||||||
@@ -2407,6 +2767,39 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Some providers (rule34video among them) hand the listing their media
|
||||||
|
// formats themselves, and yt-dlp can't extract their pages. A favorite keeps
|
||||||
|
// no formats, so for those /api/resolve comes back empty -- ask the
|
||||||
|
// providers again instead, the way the listing did: search the channel for
|
||||||
|
// the title and take the item that is this video.
|
||||||
|
const formatsFromProvider = async function(video) {
|
||||||
|
const idHead = String(video.id || '').split(':')[0];
|
||||||
|
const channelId = (idHead && idHead !== String(video.id) ? idHead : String(video.channel || '')).toLowerCase();
|
||||||
|
if (!channelId) return null;
|
||||||
|
const servers = (App.storage.getConfig().servers || []).map((entry) => Object.keys(entry)[0]);
|
||||||
|
const wanted = App.favorites && App.favorites.urlKey ? App.favorites.urlKey(video.url) : video.url;
|
||||||
|
const same = (item) => item && (item.id === video.id ||
|
||||||
|
(item.url && (App.favorites && App.favorites.urlKey ? App.favorites.urlKey(item.url) : item.url) === wanted));
|
||||||
|
for (const server of servers) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/videos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channel: channelId, query: video.title || '', page: 1, perPage: 24, server })
|
||||||
|
});
|
||||||
|
if (!response.ok) continue;
|
||||||
|
const data = await response.json();
|
||||||
|
const found = (data && Array.isArray(data.items) ? data.items : []).find(same);
|
||||||
|
if (found && hasFormats(found)) {
|
||||||
|
return { url: found.url, formats: found.formats, http_headers: found.http_headers || {}, isLive: !!found.isLive };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Next server.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
App.videos.ensureFormats = function(video) {
|
App.videos.ensureFormats = function(video) {
|
||||||
if (!video || typeof video !== 'object') return Promise.resolve(null);
|
if (!video || typeof video !== 'object') return Promise.resolve(null);
|
||||||
if (hasFormats(video.meta)) return Promise.resolve(video.meta);
|
if (hasFormats(video.meta)) return Promise.resolve(video.meta);
|
||||||
@@ -2433,13 +2826,14 @@ App.videos = App.videos || {};
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url: video.url })
|
body: JSON.stringify({ url: video.url })
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return hasFormats(data) ? data : null;
|
if (hasFormats(data)) return data;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort: playback still works through the proxy.
|
// Best-effort: playback still works through the proxy.
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return await formatsFromProvider(video);
|
||||||
})();
|
})();
|
||||||
metaCache.set(cacheKey, promise);
|
metaCache.set(cacheKey, promise);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
tests/fixtures/clip.mp4
vendored
Normal file
BIN
tests/fixtures/clip.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/dark.mp4
vendored
Normal file
BIN
tests/fixtures/dark.mp4
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/wide.mp4
vendored
Normal file
BIN
tests/fixtures/wide.mp4
vendored
Normal file
Binary file not shown.
77
tests/smoke_favorite_formats.py
Normal file
77
tests/smoke_favorite_formats.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A favorite whose site yt-dlp can't extract still gets its formats.
|
||||||
|
|
||||||
|
Providers like rule34video hand the listing their formats; a favorite keeps
|
||||||
|
none, and /api/resolve comes back empty for them. ensureFormats must then ask
|
||||||
|
the providers again and take the item that is this video.
|
||||||
|
|
||||||
|
backend/main.py &
|
||||||
|
.venv/bin/python tests/smoke_favorite_formats.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:5000/"
|
||||||
|
SERVER = "https://a.fixture.test"
|
||||||
|
FAV = {"key": "rule34video:4511669", "id": "rule34video:4511669",
|
||||||
|
"url": "https://rule34video.com/video/4511669/sindel-cassie-sonya/",
|
||||||
|
"title": "Sindel, Cassie, Sonya", "thumb": "", "channel": "Rule34video",
|
||||||
|
"uploader": "Nyl", "duration": 510, "isLive": False,
|
||||||
|
"favoriteDate": "2026-11-02T22:00:05.356Z"}
|
||||||
|
STATUS = {"id": "a", "name": "a", "online": True, "channels": [
|
||||||
|
{"id": "rule34video", "name": "Rule34video", "description": "x", "options": []}]}
|
||||||
|
FMT = [{"url": "https://rule34video.com/get_file/1/x/4511669_720p.mp4/", "height": 720, "ext": "mp4"}]
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
|
||||||
|
def videos(route):
|
||||||
|
body = json.loads(route.request.post_data or "{}")
|
||||||
|
asked.append(body)
|
||||||
|
items = [{"id": "rule34video:1", "url": "https://rule34video.com/video/1/other/", "formats": []},
|
||||||
|
{"id": FAV["id"], "url": FAV["url"], "formats": FMT}]
|
||||||
|
route.fulfill(status=200, content_type="application/json", body=json.dumps({"items": items}))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failed = 0
|
||||||
|
with sync_playwright() as p:
|
||||||
|
b = p.chromium.launch(args=["--no-sandbox", "--disable-gpu"])
|
||||||
|
page = b.new_page()
|
||||||
|
page.route(f"{SERVER}/api/status", lambda r: r.fulfill(
|
||||||
|
status=200, content_type="application/json", body=json.dumps(STATUS)))
|
||||||
|
page.route("**/api/resolve", lambda r: r.fulfill(
|
||||||
|
status=200, content_type="application/json",
|
||||||
|
body='{"formats":[],"http_headers":{},"isLive":false,"url":null}'))
|
||||||
|
page.route("**/api/videos", videos)
|
||||||
|
page.goto(BASE, wait_until="domcontentloaded")
|
||||||
|
page.evaluate("""([s, f]) => {
|
||||||
|
localStorage.setItem('config', JSON.stringify({servers: [{[s]: {}}]}));
|
||||||
|
localStorage.setItem('favorites', JSON.stringify([f]));
|
||||||
|
}""", [SERVER, FAV])
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
meta = page.evaluate("""async (f) => {
|
||||||
|
const v = Object.assign({}, f);
|
||||||
|
const m = await App.videos.ensureFormats(v);
|
||||||
|
return { m, attached: !!v.meta };
|
||||||
|
}""", FAV)
|
||||||
|
ok = bool(meta["m"]) and meta["m"]["formats"] == FMT and meta["attached"]
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] favorite gets the provider's formats {meta}")
|
||||||
|
failed += 0 if ok else 1
|
||||||
|
ok = bool(asked) and asked[0]["channel"] == "rule34video" and asked[0]["server"] == SERVER
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] asked the right channel and server {asked[:1]}")
|
||||||
|
failed += 0 if ok else 1
|
||||||
|
saved = page.evaluate("""(item) => {
|
||||||
|
localStorage.setItem('favorites', '[]');
|
||||||
|
App.favorites.toggle(Object.assign({}, item, { formats: [{ url: 'https://x/y.mp4', height: 720 }] }));
|
||||||
|
return App.favorites.getAll()[0];
|
||||||
|
}""", FAV)
|
||||||
|
ok = bool(saved) and bool(saved.get("meta")) and saved["meta"]["formats"][0]["url"] == "https://x/y.mp4"
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] a saved favorite keeps its formats")
|
||||||
|
failed += 0 if ok else 1
|
||||||
|
b.close()
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
248
tests/smoke_feed_swipe.py
Normal file
248
tests/smoke_feed_swipe.py
Normal 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())
|
||||||
124
tests/smoke_hud.py
Normal file
124
tests/smoke_hud.py
Normal 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())
|
||||||
263
tests/smoke_merge_searches.py
Normal file
263
tests/smoke_merge_searches.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Merging saved searches from different providers into one listing.
|
||||||
|
|
||||||
|
Run against a locally running backend:
|
||||||
|
|
||||||
|
backend/main.py &
|
||||||
|
.venv/bin/python tests/smoke_merge_searches.py
|
||||||
|
|
||||||
|
Two fixture providers, one saved search on each, merged into one. What the
|
||||||
|
merge has to do that a channel group cannot: a group's channels all live on
|
||||||
|
one server and share one query, where these two share nothing at all. So the
|
||||||
|
load-bearing check is that the grid comes back holding videos from *both*
|
||||||
|
providers, taken alternately rather than one provider's page and then the
|
||||||
|
other's.
|
||||||
|
|
||||||
|
Both providers are fixtures, so "interleaved" is something the test can read
|
||||||
|
off the ids rather than infer: provider A answers a-0, a-1 ... and B answers
|
||||||
|
b-0, b-1 ...
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:5000/"
|
||||||
|
SERVER_A = "https://a.fixture.test"
|
||||||
|
SERVER_B = "https://b.fixture.test"
|
||||||
|
CHANNEL_A = "alpha"
|
||||||
|
CHANNEL_B = "beta"
|
||||||
|
# fetchMergedBatch asks each member for this many (GROUP_CHANNEL_PAGE_SIZE).
|
||||||
|
PER_MEMBER = 4
|
||||||
|
|
||||||
|
|
||||||
|
def status(name, channel_id, channel_name):
|
||||||
|
return {
|
||||||
|
"id": name,
|
||||||
|
"name": name,
|
||||||
|
"online": True,
|
||||||
|
"channels": [{
|
||||||
|
"id": channel_id,
|
||||||
|
"name": channel_name,
|
||||||
|
"description": "A channel that only exists for this test.",
|
||||||
|
"options": [],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_A = status("alpha-co", CHANNEL_A, "Alpha Tube")
|
||||||
|
STATUS_B = status("beta-co", CHANNEL_B, "Beta Tube")
|
||||||
|
|
||||||
|
# Two saved searches, one per provider, seeded straight into the store -- the
|
||||||
|
# saving of a search is smoke_saved_searches' business, not this test's.
|
||||||
|
SEED = """([a, b, ca, cb]) => {
|
||||||
|
localStorage.setItem('config', JSON.stringify({
|
||||||
|
servers: [{ [a]: {} }, { [b]: {} }]
|
||||||
|
}));
|
||||||
|
localStorage.removeItem('session');
|
||||||
|
localStorage.setItem('favorites', JSON.stringify([]));
|
||||||
|
localStorage.setItem('savedSearches', JSON.stringify([
|
||||||
|
{ id: 'ss_a', name: 'Alpha pick', server: a, channelId: ca,
|
||||||
|
channelName: 'Alpha Tube', query: 'reef', options: {}, optionLabels: [],
|
||||||
|
createdAt: 1, seenIds: [], lastViewedAt: 1, lastCheckedAt: 1, newCount: 0 },
|
||||||
|
{ id: 'ss_b', name: 'Beta pick', server: b, channelId: cb,
|
||||||
|
channelName: 'Beta Tube', query: 'ridge', options: {}, optionLabels: [],
|
||||||
|
createdAt: 2, seenIds: [], lastViewedAt: 2, lastCheckedAt: 2, newCount: 0 }
|
||||||
|
]));
|
||||||
|
}"""
|
||||||
|
|
||||||
|
GRID_IDS = """() => (App.state.loadedVideos || []).map(v => v.id)"""
|
||||||
|
|
||||||
|
SESSION = """() => {
|
||||||
|
const s = App.storage.getSession();
|
||||||
|
return {
|
||||||
|
merged: !!(s && s.channel && s.channel.isMerged),
|
||||||
|
channelId: s && s.channel ? s.channel.id : null,
|
||||||
|
members: s && s.channel && s.channel.members
|
||||||
|
? s.channel.members.map(m => m.server + '|' + m.channelId + '|' + m.query)
|
||||||
|
: null,
|
||||||
|
name: s && s.channel ? s.channel.name : null
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
|
||||||
|
STORE = """() => App.savedSearches.getAll().map(e => ({
|
||||||
|
id: e.id, name: e.name,
|
||||||
|
members: e.members ? e.members.map(m => m.server) : 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 install_routes(page, seen):
|
||||||
|
page.route(f"{SERVER_A}/api/status", lambda route: route.fulfill(
|
||||||
|
status=200, content_type="application/json", body=json.dumps(STATUS_A)))
|
||||||
|
page.route(f"{SERVER_B}/api/status", lambda route: route.fulfill(
|
||||||
|
status=200, content_type="application/json", body=json.dumps(STATUS_B)))
|
||||||
|
|
||||||
|
def videos(route):
|
||||||
|
try:
|
||||||
|
body = json.loads(route.request.post_data or "{}")
|
||||||
|
except ValueError:
|
||||||
|
body = {}
|
||||||
|
server = body.get("server") or ""
|
||||||
|
page_no = int(body.get("page") or 1)
|
||||||
|
per = int(body.get("perPage") or 12)
|
||||||
|
tag = "a" if server == SERVER_A else ("b" if server == SERVER_B else "?")
|
||||||
|
seen.append({"server": server, "channel": body.get("channel"),
|
||||||
|
"query": body.get("query"), "page": page_no, "perPage": per})
|
||||||
|
start = (page_no - 1) * per
|
||||||
|
items = [{
|
||||||
|
"id": f"{tag}-{start + i}",
|
||||||
|
"title": f"{tag} clip {start + i}",
|
||||||
|
"url": f"{server}/v{start + i}",
|
||||||
|
"thumb": "", "channel": body.get("channel"),
|
||||||
|
"duration": 30, "isLive": False, "tags": [],
|
||||||
|
} for i in range(per)]
|
||||||
|
route.fulfill(status=200, content_type="application/json",
|
||||||
|
body=json.dumps({"items": items, "pageInfo": {"hasNextPage": True}}))
|
||||||
|
|
||||||
|
page.route("**/api/videos", videos)
|
||||||
|
|
||||||
|
|
||||||
|
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": 1280, "height": 900})
|
||||||
|
crashes = []
|
||||||
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||||
|
seen = []
|
||||||
|
install_routes(page, seen)
|
||||||
|
|
||||||
|
page.goto(BASE, wait_until="domcontentloaded")
|
||||||
|
page.evaluate(SEED, [SERVER_A, SERVER_B, CHANNEL_A, CHANNEL_B])
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
page.wait_for_selector(".video-card", timeout=30000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
print("\nmerging the two")
|
||||||
|
page.click("#saved-searches-btn")
|
||||||
|
page.wait_for_selector("#saved-panel.open", timeout=10000)
|
||||||
|
rows = page.eval_on_selector_all(".saved-row", "els => els.length")
|
||||||
|
c.ok("both saved searches are listed", rows == 2, str(rows))
|
||||||
|
|
||||||
|
page.click("#saved-select-btn")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
page.click('.saved-row[data-id="ss_a"] .saved-row-main')
|
||||||
|
page.click('.saved-row[data-id="ss_b"] .saved-row-main')
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
label = page.eval_on_selector("#saved-merge-btn", "el => el.textContent")
|
||||||
|
c.ok("the merge action counts what is picked",
|
||||||
|
"2" in label, label)
|
||||||
|
|
||||||
|
page.click("#saved-merge-btn")
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
# The new merge opens straight into its rename field; Enter keeps the
|
||||||
|
# name it was given.
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
|
store = page.evaluate(STORE)
|
||||||
|
c.ok("the two originals were replaced by one merge", len(store) == 1, str(store))
|
||||||
|
c.ok("and it holds a member from each provider",
|
||||||
|
len(store) == 1 and sorted(store[0]["members"] or []) == sorted([SERVER_A, SERVER_B]),
|
||||||
|
str(store))
|
||||||
|
|
||||||
|
print("\nopening it")
|
||||||
|
seen.clear()
|
||||||
|
page.click(f'.saved-row[data-id="{store[0]["id"]}"] .saved-row-main')
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
|
||||||
|
session = page.evaluate(SESSION)
|
||||||
|
c.ok("the session is a merge", session["merged"], str(session))
|
||||||
|
c.ok("carrying both members, each with its own words",
|
||||||
|
sorted(session["members"] or []) == sorted([
|
||||||
|
f"{SERVER_A}|{CHANNEL_A}|reef", f"{SERVER_B}|{CHANNEL_B}|ridge"]),
|
||||||
|
str(session["members"]))
|
||||||
|
|
||||||
|
asked = {(r["server"], r["query"]) for r in seen}
|
||||||
|
c.ok("both providers were asked, each for its own search",
|
||||||
|
asked == {(SERVER_A, "reef"), (SERVER_B, "ridge")}, str(asked))
|
||||||
|
|
||||||
|
ids = page.evaluate(GRID_IDS)
|
||||||
|
kinds = [i.split("-")[0] for i in ids]
|
||||||
|
c.ok("the grid holds videos from both providers",
|
||||||
|
"a" in kinds and "b" in kinds, str(ids[:10]))
|
||||||
|
# The point of the exercise: alternated, not one provider then the other.
|
||||||
|
head = kinds[:2 * PER_MEMBER]
|
||||||
|
c.ok("taken alternately rather than one provider at a time",
|
||||||
|
head == ["a", "b"] * PER_MEMBER, str(head))
|
||||||
|
|
||||||
|
print("\nthe search box stays out of it")
|
||||||
|
box = page.evaluate("""() => {
|
||||||
|
const el = document.getElementById('search-input');
|
||||||
|
return { disabled: el.disabled, value: el.value, placeholder: el.placeholder };
|
||||||
|
}""")
|
||||||
|
c.ok("the box is off while a merge is open", box["disabled"], str(box))
|
||||||
|
c.ok("and says why", "own words" in box["placeholder"], str(box["placeholder"]))
|
||||||
|
|
||||||
|
print("\nit survives a reload")
|
||||||
|
page.reload(wait_until="load")
|
||||||
|
page.wait_for_selector(".video-card", timeout=30000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
again = page.evaluate(SESSION)
|
||||||
|
c.ok("still a merge after reloading", again["merged"], str(again))
|
||||||
|
c.ok("with the same members",
|
||||||
|
sorted(again["members"] or []) == sorted(session["members"] or []),
|
||||||
|
str(again["members"]))
|
||||||
|
reloaded = [i.split("-")[0] for i in page.evaluate(GRID_IDS)]
|
||||||
|
c.ok("and still fed by both providers",
|
||||||
|
"a" in reloaded and "b" in reloaded, str(reloaded[:10]))
|
||||||
|
|
||||||
|
print("\nturning one member off")
|
||||||
|
page.evaluate("""(host) => {
|
||||||
|
const s = App.storage.getSession();
|
||||||
|
const keep = s.channel.members.filter(m => m.server.indexOf(host) !== -1);
|
||||||
|
s.options = { __mergedMembers: keep.map(m => ({ id: m.memberId, title: m.channelName })) };
|
||||||
|
App.storage.setSession(s);
|
||||||
|
}""", "a.fixture")
|
||||||
|
page.evaluate("() => { App.videos.resetGrid(); return App.videos.loadVideos(); }")
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
only = [i.split("-")[0] for i in page.evaluate(GRID_IDS)]
|
||||||
|
c.ok("only the member left on is asked",
|
||||||
|
only and set(only) == {"a"}, str(only[:10]))
|
||||||
|
|
||||||
|
print("\nsearching from inside a merge steps out of it")
|
||||||
|
# A tag or an uploader tapped on a card comes through handleSearch.
|
||||||
|
# There is nowhere in a merge to put new words, so rather than reload
|
||||||
|
# the same videos and look broken, it lands on one real channel.
|
||||||
|
page.evaluate("() => App.videos.handleSearch('anemone')")
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
after = page.evaluate(SESSION)
|
||||||
|
c.ok("the session is no longer a merge", not after["merged"], str(after))
|
||||||
|
c.ok("it landed on a member's own channel",
|
||||||
|
after["channelId"] in (CHANNEL_A, CHANNEL_B), str(after["channelId"]))
|
||||||
|
box2 = page.evaluate("""() => {
|
||||||
|
const el = document.getElementById('search-input');
|
||||||
|
return { disabled: el.disabled, value: el.value };
|
||||||
|
}""")
|
||||||
|
c.ok("and the box is usable again", not box2["disabled"], str(box2))
|
||||||
|
c.ok("holding what was searched for", box2["value"] == "anemone", str(box2))
|
||||||
|
searched = [r for r in seen if r["query"] == "anemone"]
|
||||||
|
c.ok("which is what the provider was asked for", bool(searched), str(seen[-3:]))
|
||||||
|
|
||||||
|
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())
|
||||||
332
tests/smoke_parallel.py
Normal file
332
tests/smoke_parallel.py
Normal 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
148
tests/smoke_peek.py
Normal 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
292
tests/smoke_poster_black.py
Normal 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())
|
||||||
308
tests/smoke_poster_capture.py
Normal file
308
tests/smoke_poster_capture.py
Normal 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
141
tests/smoke_privacy.py
Normal 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())
|
||||||
208
tests/smoke_rotate.py
Normal file
208
tests/smoke_rotate.py
Normal 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())
|
||||||
240
tests/smoke_rotate_smooth.py
Normal file
240
tests/smoke_rotate_smooth.py
Normal 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())
|
||||||
416
tests/smoke_saved_searches.py
Normal file
416
tests/smoke_saved_searches.py
Normal 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())
|
||||||
249
tests/smoke_search_history.py
Normal file
249
tests/smoke_search_history.py
Normal 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
348
tests/smoke_split_reuse.py
Normal 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())
|
||||||
188
tests/unit_direct_probe.js
Normal file
188
tests/unit_direct_probe.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user