Compare commits
10 Commits
7624ca559a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a795442634 | ||
|
|
b478c51551 | ||
|
|
191c81e55e | ||
|
|
2abdc31f56 | ||
|
|
76a078b9b5 | ||
|
|
451bf0f983 | ||
|
|
764e3416a3 | ||
|
|
f0df53365d | ||
|
|
6e5ab68a94 | ||
|
|
e603111d70 |
@@ -1047,6 +1047,12 @@ body.favorites-view-open .favorites-empty {
|
||||
precomputed (top,left). */
|
||||
}
|
||||
|
||||
/* Each card's layout and paint stay its own business, so inserting one during
|
||||
a scroll cannot make the browser reconsider the rest of the grid. */
|
||||
.video-card {
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 16px 28px var(--shadow);
|
||||
@@ -1318,7 +1324,7 @@ body.theme-light .video-menu-btn {
|
||||
}
|
||||
|
||||
/* Live streams can't be seeked, so hide the feed scrubber. */
|
||||
.feed-slide.is-live .feed-timeline {
|
||||
.feed-pane.is-live .feed-timeline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2152,6 +2158,9 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* One screenful. It holds the pane tree rather than a video directly: with
|
||||
several panes, a step shows that many videos at once and one swipe advances
|
||||
the whole set. */
|
||||
.feed-slide {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -2159,6 +2168,34 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
height: 100dvh;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* A branch of the tree: two children sharing the space, side by side or
|
||||
stacked. Nesting these is what makes any arrangement reachable. */
|
||||
.feed-split {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.feed-split.is-row { flex-direction: row; }
|
||||
.feed-split.is-col { flex-direction: column; }
|
||||
|
||||
/* min-* is what stops a flex child refusing to shrink below its content. */
|
||||
.feed-split > .feed-pane,
|
||||
.feed-split > .feed-split {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.feed-pane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -2166,6 +2203,45 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.feed-pane-tools {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
/* Above the video, below the right-hand controls it sits beside. */
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.feed-pane-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(244, 232, 212, 0.18);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.feed-pane-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.feed-pane-mute.is-muted {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* The HUD's idle fade takes the panel tools with it, like every other control. */
|
||||
.feed-hud-idle .feed-pane-tools {
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.feed-poster,
|
||||
.feed-video {
|
||||
position: absolute;
|
||||
@@ -2179,7 +2255,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.feed-slide.is-loaded .feed-poster {
|
||||
.feed-pane.is-loaded .feed-poster {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2304,6 +2380,22 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
}
|
||||
|
||||
/* Per-slide favorite (heart) button on the feed HUD right rail. */
|
||||
/* The right-hand controls stack upwards from just above the caption, rather
|
||||
than sitting at fixed distances from the bottom. A pane can be a third of
|
||||
the viewport tall, and viewport-scale offsets put these outside it -- clipped
|
||||
by the pane's own overflow and unreachable. */
|
||||
.feed-pane .feed-fav-btn,
|
||||
.feed-pane .feed-pip-btn,
|
||||
.feed-pane .feed-format-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: auto;
|
||||
top: 56px;
|
||||
}
|
||||
|
||||
.feed-pane .feed-pip-btn { top: 100px; }
|
||||
.feed-pane .feed-format-btn { top: 144px; }
|
||||
|
||||
.feed-fav-btn {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
@@ -2372,13 +2464,13 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
|
||||
interactive (pointer-events untouched) so the buttons keep working while
|
||||
invisible; any pointer/scroll activity reveals them again (see App.feed). */
|
||||
body.feed-hud-idle .feed-info,
|
||||
body.feed-hud-idle .feed-timeline,
|
||||
body.feed-hud-idle .feed-mute-btn,
|
||||
body.feed-hud-idle .feed-fav-btn,
|
||||
body.feed-hud-idle .feed-pip-btn,
|
||||
body.feed-hud-idle .feed-format-btn,
|
||||
body.feed-hud-idle .mode-toggle-btn {
|
||||
.feed-hud-idle .feed-info,
|
||||
.feed-hud-idle .feed-timeline,
|
||||
.feed-hud-idle .feed-mute-btn,
|
||||
.feed-hud-idle .feed-fav-btn,
|
||||
.feed-hud-idle .feed-pip-btn,
|
||||
.feed-hud-idle .feed-format-btn,
|
||||
.feed-hud-idle .mode-toggle-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2612,6 +2704,257 @@ body.theme-light .video-card img:not(.is-loaded) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Channel picker ---------------------------------------------------- */
|
||||
/* The trigger in the menu drawer: what's being read now, favicon and all. */
|
||||
.channel-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.channel-trigger:hover,
|
||||
.channel-trigger:focus-visible {
|
||||
border-color: var(--border-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.channel-trigger-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-trigger-name,
|
||||
.channel-trigger-note {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-trigger-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.channel-trigger-caret {
|
||||
flex-shrink: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--text-secondary);
|
||||
}
|
||||
|
||||
/* A favicon, with the channel's initial behind it for the ones that never
|
||||
arrive -- a row is never a name beside an empty square. */
|
||||
.channel-mark {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: rgba(201, 165, 103, 0.14);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-mark::before {
|
||||
content: attr(data-letter);
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-favicon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 3px;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.channel-picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4200;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 12px 12px 12px;
|
||||
background: rgba(10, 8, 4, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.channel-picker.open { display: flex; }
|
||||
|
||||
.channel-picker-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 100%);
|
||||
max-height: min(74vh, 700px);
|
||||
/* Solid, not the usual translucent panel: the section headings stick over
|
||||
the rows as they scroll, and a see-through heading is unreadable. */
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 30px 70px var(--shadow);
|
||||
overflow: hidden;
|
||||
animation: card-rise 0.2s ease both;
|
||||
}
|
||||
|
||||
.channel-picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 10px 18px;
|
||||
}
|
||||
|
||||
.channel-picker-head h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-search {
|
||||
margin: 0 18px 12px 18px;
|
||||
padding: 11px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-search::placeholder { color: var(--text-secondary); }
|
||||
.channel-search:focus { outline: none; border-color: var(--border-hover); }
|
||||
|
||||
.channel-picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px 10px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.channel-section {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 10px 8px 6px 8px;
|
||||
background: var(--bg-primary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-row.is-active { background: var(--bg-tertiary); }
|
||||
|
||||
.channel-row.is-current {
|
||||
border-color: var(--border-hover);
|
||||
background: rgba(201, 165, 103, 0.10);
|
||||
}
|
||||
|
||||
.channel-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-row-name {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-row-note {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.channel-tag {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row-flag {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-picker-empty {
|
||||
padding: 8px 20px 20px 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.channel-picker { padding: 0; }
|
||||
|
||||
.channel-picker-box {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */
|
||||
.feed-title { font-family: var(--font-display); }
|
||||
|
||||
|
||||
@@ -58,8 +58,17 @@
|
||||
<select id="source-select"></select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="channel-select">Channel</label>
|
||||
<select id="channel-select"></select>
|
||||
<label for="channel-picker-btn">Channel</label>
|
||||
<button id="channel-picker-btn" class="channel-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<span class="channel-mark" id="channel-trigger-mark">
|
||||
<img class="channel-favicon" id="channel-trigger-icon" alt="" decoding="async">
|
||||
</span>
|
||||
<span class="channel-trigger-text">
|
||||
<span class="channel-trigger-name" id="channel-trigger-name">No channel</span>
|
||||
<span class="channel-trigger-note" id="channel-trigger-note"></span>
|
||||
</span>
|
||||
<span class="channel-trigger-caret" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -194,6 +203,19 @@
|
||||
|
||||
<button id="back-to-top" class="back-to-top" type="button" title="Back to top" aria-label="Back to top">↑</button>
|
||||
|
||||
<div id="channel-picker" class="channel-picker" aria-hidden="true">
|
||||
<div class="channel-picker-box" role="dialog" aria-modal="true" aria-labelledby="channel-picker-title">
|
||||
<div class="channel-picker-head">
|
||||
<h3 id="channel-picker-title">Channel</h3>
|
||||
<button id="channel-picker-close" class="close-btn" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<input id="channel-search" class="channel-search" type="text" placeholder="Search channels…"
|
||||
autocomplete="off" spellcheck="false" aria-controls="channel-picker-list">
|
||||
<div id="channel-picker-list" class="channel-picker-list" role="listbox" aria-label="Channels"></div>
|
||||
<div id="channel-picker-empty" class="channel-picker-empty">No channel matches that.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="command-palette" class="command-palette" aria-hidden="true">
|
||||
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<input id="cmdk-input" class="cmdk-input" type="text" placeholder="Type a command or search… (⌘K)" autocomplete="off" spellcheck="false">
|
||||
|
||||
@@ -194,36 +194,138 @@ App.customPlayer = App.customPlayer || {};
|
||||
// tab/app is backgrounded while a video is playing (Safari does this
|
||||
// natively for inline video; Chrome/Android need an explicit call).
|
||||
// -----------------------------------------------------------------
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return !!(document.pictureInPictureEnabled);
|
||||
// iOS has picture-in-picture, but not this API: Safari on iPhone and iPad
|
||||
// never implemented requestPictureInPicture, and exposes WebKit's older
|
||||
// presentation-mode switch instead. document.pictureInPictureEnabled is
|
||||
// undefined there, so every check below it used to answer "no" and the
|
||||
// button was hidden on the one platform where people most want it.
|
||||
//
|
||||
// The difference is confined here. enterPiP/exitPiP/pipElement speak for
|
||||
// both, and bindPiPEvents re-fires WebKit's non-bubbling
|
||||
// webkitpresentationmodechanged as the standard enter/leave events, so the
|
||||
// delegated listeners elsewhere work unchanged.
|
||||
const WEBKIT_PIP = 'picture-in-picture';
|
||||
let webkitPipElement = null;
|
||||
|
||||
const standardPiP = () => !!document.pictureInPictureEnabled;
|
||||
const webkitPiP = (video) => !!(video && typeof video.webkitSetPresentationMode === 'function');
|
||||
|
||||
// No element to ask about: does this browser have either API at all?
|
||||
let webkitProbe = null;
|
||||
const webkitAvailable = function() {
|
||||
if (webkitProbe === null) {
|
||||
webkitProbe = typeof HTMLVideoElement !== 'undefined' &&
|
||||
(typeof HTMLVideoElement.prototype.webkitSetPresentationMode === 'function' ||
|
||||
webkitPiP(document.createElement('video')));
|
||||
}
|
||||
return webkitProbe;
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
|
||||
try {
|
||||
if (document.pictureInPictureElement === video) {
|
||||
await document.exitPictureInPicture();
|
||||
} else {
|
||||
// Deliberately the method's presence rather than
|
||||
// video.webkitSupportsPresentationMode(): that answers false until a video
|
||||
// track is loaded, and feed videos are preload="none" until they go active
|
||||
// -- it would hide the button on exactly the videos about to be able to
|
||||
// use it.
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return standardPiP() || webkitAvailable();
|
||||
};
|
||||
|
||||
App.customPlayer.pipElement = function() {
|
||||
return document.pictureInPictureElement || webkitPipElement || null;
|
||||
};
|
||||
|
||||
App.customPlayer.enterPiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (standardPiP()) {
|
||||
if (video.disablePictureInPicture) return false;
|
||||
try {
|
||||
await video.requestPictureInPicture();
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!webkitPiP(video)) return false;
|
||||
try {
|
||||
// Synchronous, and it needs the user gesture that got us here.
|
||||
video.webkitSetPresentationMode(WEBKIT_PIP);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
App.customPlayer.exitPiP = async function() {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const video = webkitPipElement;
|
||||
if (!webkitPiP(video)) return;
|
||||
try { video.webkitSetPresentationMode('inline'); } catch (err) { /* already gone */ }
|
||||
};
|
||||
|
||||
App.customPlayer.bindPiPEvents = function(video) {
|
||||
if (standardPiP() || !webkitPiP(video)) return function destroy() {};
|
||||
// The same event announces fullscreen and inline, so only a real change
|
||||
// in picture-in-picture-ness is worth reporting.
|
||||
let wasPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
const onChange = function() {
|
||||
const isPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
if (isPip === wasPip) return;
|
||||
wasPip = isPip;
|
||||
if (isPip) webkitPipElement = video;
|
||||
else if (webkitPipElement === video) webkitPipElement = null;
|
||||
video.dispatchEvent(new CustomEvent(
|
||||
isPip ? 'enterpictureinpicture' : 'leavepictureinpicture', { bubbles: true }));
|
||||
};
|
||||
video.addEventListener('webkitpresentationmodechanged', onChange);
|
||||
return function destroy() {
|
||||
video.removeEventListener('webkitpresentationmodechanged', onChange);
|
||||
if (webkitPipElement === video) webkitPipElement = null;
|
||||
};
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (App.customPlayer.pipElement() === video) {
|
||||
await App.customPlayer.exitPiP();
|
||||
return true;
|
||||
}
|
||||
return App.customPlayer.enterPiP(video);
|
||||
};
|
||||
|
||||
// Asking for picture-in-picture the moment a tab is hidden is a request
|
||||
// with no user gesture behind it, and browsers refuse those -- which is why
|
||||
// the imperative call below fails silently. `autoPictureInPicture` is the
|
||||
// declarative form made for exactly this: the browser is told in advance
|
||||
// which video should follow the reader out, and does it itself. Safari
|
||||
// honours it outright; Chrome honours it for installed apps. The call is
|
||||
// kept as a fallback for anywhere the flag is ignored but the request is
|
||||
// allowed.
|
||||
App.customPlayer.setAutoPiP = function(video, on) {
|
||||
if (!video) return;
|
||||
try { video.autoPictureInPicture = !!on; } catch (err) { /* unsupported */ }
|
||||
if (on) video.setAttribute('autopictureinpicture', '');
|
||||
else video.removeAttribute('autopictureinpicture');
|
||||
};
|
||||
|
||||
App.customPlayer.bindAutoPiP = function(video) {
|
||||
if (!video) return function destroy() {};
|
||||
App.customPlayer.setAutoPiP(video, true);
|
||||
const unbindEvents = App.customPlayer.bindPiPEvents(video);
|
||||
const trigger = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
|
||||
if (document.pictureInPictureElement) return;
|
||||
if (!App.customPlayer.supportsPiP() || video.disablePictureInPicture) return;
|
||||
if (App.customPlayer.pipElement()) return;
|
||||
if (video.paused || video.ended) return;
|
||||
video.requestPictureInPicture().catch(() => {});
|
||||
App.customPlayer.enterPiP(video);
|
||||
};
|
||||
document.addEventListener('visibilitychange', trigger);
|
||||
window.addEventListener('pagehide', trigger);
|
||||
return function destroy() {
|
||||
App.customPlayer.setAutoPiP(video, false);
|
||||
unbindEvents();
|
||||
document.removeEventListener('visibilitychange', trigger);
|
||||
window.removeEventListener('pagehide', trigger);
|
||||
};
|
||||
|
||||
@@ -196,11 +196,19 @@ App.enhance = App.enhance || {};
|
||||
out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } });
|
||||
});
|
||||
}
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
if (channelSelect) {
|
||||
Array.from(channelSelect.options).forEach((opt) => {
|
||||
if (opt.value === channelSelect.value) return;
|
||||
out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } });
|
||||
// The channel list is the picker's (App.ui.channels), not a
|
||||
// <select>'s -- same entries, drawn as palette rows.
|
||||
if (App.ui && App.ui.channels) {
|
||||
const current = App.storage && App.storage.getSession ?
|
||||
App.storage.getSession() : null;
|
||||
const currentId = (current && current.channel) ? current.channel.id : '';
|
||||
App.ui.channels.entries().forEach((entry) => {
|
||||
if (entry.id === currentId) return;
|
||||
out.push({
|
||||
label: entry.label,
|
||||
hint: entry.group || 'Channel',
|
||||
run: () => App.ui.channels.choose(entry.id)
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -229,12 +229,356 @@ App.ui = App.ui || {};
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Channel picker
|
||||
//
|
||||
// A <select> of ninety channels tells the reader almost nothing: a wall of
|
||||
// bare names, nothing to say which site a name belongs to or what it
|
||||
// carries, and no way to look for one. The server sends far more than the
|
||||
// name -- a favicon, a description, tags, whether the channel still says
|
||||
// "work in progress" -- so the picker shows that, and lets the reader type.
|
||||
//
|
||||
// The <select> was also where the command palette read its channel actions
|
||||
// from, so the list lives here now and the palette asks for it.
|
||||
// ---------------------------------------------------------------------
|
||||
App.ui.channels = (function() {
|
||||
// Sections in display order: a group, then whatever the server didn't
|
||||
// put in one. Rebuilt whenever the menu renders, which is whenever the
|
||||
// server, its status, or the selection changes.
|
||||
let sections = [];
|
||||
let matched = []; // the rows the current search leaves on screen
|
||||
let activeIndex = 0;
|
||||
let bound = false;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
const activeServerData = function() {
|
||||
const session = App.storage.getSession();
|
||||
if (!session) return null;
|
||||
const entry = App.storage.getServerEntries()
|
||||
.find((candidate) => candidate.url === session.server);
|
||||
return (entry && entry.data) || null;
|
||||
};
|
||||
|
||||
// Everything about a channel worth searching, in one string: a reader
|
||||
// typing "jav", "leaks" or the site's own name should all land.
|
||||
const haystack = function(parts) {
|
||||
return parts.filter(Boolean).join(' ').toLowerCase();
|
||||
};
|
||||
|
||||
const channelRow = function(channel, groupTitle) {
|
||||
const tags = Array.isArray(channel.tags) ? channel.tags : [];
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name || channel.id,
|
||||
note: channel.description || '',
|
||||
favicon: channel.favicon || '',
|
||||
tags: tags.slice(0, 3),
|
||||
// "work in progress" is the server's own word for a channel
|
||||
// that may not answer; worth saying before it's picked.
|
||||
flag: channel.status && channel.status !== 'active' ? channel.status :
|
||||
(channel.premium ? 'premium' : ''),
|
||||
group: groupTitle || '',
|
||||
search: haystack([channel.name, channel.id, channel.description,
|
||||
tags.join(' '), groupTitle])
|
||||
};
|
||||
};
|
||||
|
||||
const build = function() {
|
||||
const data = activeServerData();
|
||||
const channels = (data && Array.isArray(data.channels)) ? data.channels : [];
|
||||
const groups = (data && Array.isArray(data.channelGroups)) ? data.channelGroups : [];
|
||||
const byId = new Map(channels.map((channel) => [channel.id, channel]));
|
||||
const grouped = new Set();
|
||||
sections = [];
|
||||
|
||||
groups.forEach((group) => {
|
||||
const ids = (Array.isArray(group.channelIds) ? group.channelIds : [])
|
||||
.filter((id) => byId.has(id));
|
||||
if (!ids.length) return;
|
||||
ids.forEach((id) => grouped.add(id));
|
||||
const title = group.title || group.id;
|
||||
sections.push({
|
||||
title: title,
|
||||
rows: [{
|
||||
id: `group:${group.id}`,
|
||||
name: `All ${title}`,
|
||||
// Every group row would otherwise be an "A" for "All".
|
||||
mark: title,
|
||||
note: `Every channel in ${title}, interleaved.`,
|
||||
favicon: '',
|
||||
tags: [],
|
||||
flag: ids.length === 1 ? '1 channel' : `${ids.length} channels`,
|
||||
group: title,
|
||||
search: haystack(['all', title, group.id])
|
||||
}].concat(ids.map((id) => channelRow(byId.get(id), title)))
|
||||
});
|
||||
});
|
||||
|
||||
const ungrouped = channels
|
||||
.filter((channel) => !grouped.has(channel.id))
|
||||
.sort((a, b) => (a.name || a.id || '').toLowerCase()
|
||||
.localeCompare((b.name || b.id || '').toLowerCase()));
|
||||
if (ungrouped.length) {
|
||||
sections.push({
|
||||
title: sections.length ? 'Everything else' : 'Channels',
|
||||
rows: ungrouped.map((channel) => channelRow(channel, ''))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The letter behind a favicon that hasn't arrived (or never will), so a
|
||||
// row is never a name next to an empty square.
|
||||
const markFor = function(row) {
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'channel-mark';
|
||||
mark.dataset.letter = (row.mark || row.name || '?').trim().charAt(0).toUpperCase();
|
||||
if (row.favicon) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'channel-favicon';
|
||||
img.alt = '';
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
mark.appendChild(img);
|
||||
// The same route race, proxy fallback and retries every other
|
||||
// remote picture in the app goes through.
|
||||
App.videos.attachThumbnail(img, row.favicon);
|
||||
}
|
||||
return mark;
|
||||
};
|
||||
|
||||
const rowButton = function(row, currentId) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'channel-row' + (row.id === currentId ? ' is-current' : '');
|
||||
button.setAttribute('role', 'option');
|
||||
button.setAttribute('aria-selected', row.id === currentId ? 'true' : 'false');
|
||||
button.dataset.channelId = row.id;
|
||||
button.appendChild(markFor(row));
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'channel-row-text';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'channel-row-name';
|
||||
name.textContent = row.name;
|
||||
text.appendChild(name);
|
||||
if (row.note) {
|
||||
const note = document.createElement('span');
|
||||
note.className = 'channel-row-note';
|
||||
note.textContent = row.note;
|
||||
text.appendChild(note);
|
||||
}
|
||||
if (row.tags.length) {
|
||||
const tags = document.createElement('span');
|
||||
tags.className = 'channel-row-tags';
|
||||
row.tags.forEach((tag) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'channel-tag';
|
||||
chip.textContent = tag;
|
||||
tags.appendChild(chip);
|
||||
});
|
||||
text.appendChild(tags);
|
||||
}
|
||||
button.appendChild(text);
|
||||
|
||||
if (row.flag) {
|
||||
const flag = document.createElement('span');
|
||||
flag.className = 'channel-row-flag';
|
||||
flag.textContent = row.flag;
|
||||
button.appendChild(flag);
|
||||
}
|
||||
return button;
|
||||
};
|
||||
|
||||
const render = function() {
|
||||
const list = el('channel-picker-list');
|
||||
const empty = el('channel-picker-empty');
|
||||
const search = el('channel-search');
|
||||
if (!list) return;
|
||||
const query = (search ? search.value : '').trim().toLowerCase();
|
||||
const session = App.storage.getSession();
|
||||
const currentId = (session && session.channel) ? session.channel.id : '';
|
||||
|
||||
list.innerHTML = '';
|
||||
matched = [];
|
||||
sections.forEach((section) => {
|
||||
const rows = query
|
||||
? section.rows.filter((row) => row.search.includes(query))
|
||||
: section.rows;
|
||||
if (!rows.length) return;
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'channel-section';
|
||||
heading.textContent = section.title;
|
||||
list.appendChild(heading);
|
||||
rows.forEach((row) => {
|
||||
const button = rowButton(row, currentId);
|
||||
const index = matched.length;
|
||||
button.addEventListener('click', () => App.ui.channels.choose(row.id));
|
||||
button.addEventListener('pointermove', () => setActive(index));
|
||||
list.appendChild(button);
|
||||
matched.push(button);
|
||||
});
|
||||
});
|
||||
if (empty) empty.hidden = matched.length > 0;
|
||||
// A shorter list under an unchanged scroll position hides its own
|
||||
// first hits, so every search starts back at the top.
|
||||
if (query) list.scrollTop = 0;
|
||||
// A search starts on its first hit; an unsearched list starts on the
|
||||
// channel already being read, which is also what gets scrolled to.
|
||||
const current = matched.findIndex((button) => button.classList.contains('is-current'));
|
||||
setActive(query || current < 0 ? 0 : current, !query);
|
||||
};
|
||||
|
||||
const setActive = function(index, scroll) {
|
||||
if (!matched.length) { activeIndex = 0; return; }
|
||||
activeIndex = Math.max(0, Math.min(index, matched.length - 1));
|
||||
matched.forEach((button, i) => button.classList.toggle('is-active', i === activeIndex));
|
||||
if (scroll && matched[activeIndex]) {
|
||||
matched[activeIndex].scrollIntoView({ block: 'center' });
|
||||
}
|
||||
};
|
||||
|
||||
const step = function(delta) {
|
||||
setActive(activeIndex + delta);
|
||||
const button = matched[activeIndex];
|
||||
if (button) button.scrollIntoView({ block: 'nearest' });
|
||||
};
|
||||
|
||||
const open = function() {
|
||||
const picker = el('channel-picker');
|
||||
const search = el('channel-search');
|
||||
const trigger = el('channel-picker-btn');
|
||||
if (!picker) return;
|
||||
build();
|
||||
if (search) search.value = '';
|
||||
// Shown before it is filled: scrolling the current channel into
|
||||
// view can't work while the list is still display:none.
|
||||
picker.classList.add('open');
|
||||
picker.setAttribute('aria-hidden', 'false');
|
||||
render();
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||
// Typing is the point of the thing -- but not on a phone, where
|
||||
// focusing the field throws up the keyboard over the list.
|
||||
if (search && window.matchMedia('(min-width: 720px)').matches) {
|
||||
requestAnimationFrame(() => search.focus());
|
||||
}
|
||||
};
|
||||
|
||||
const close = function() {
|
||||
const picker = el('channel-picker');
|
||||
const trigger = el('channel-picker-btn');
|
||||
if (picker) {
|
||||
picker.classList.remove('open');
|
||||
picker.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
|
||||
const bind = function() {
|
||||
if (bound) return;
|
||||
const picker = el('channel-picker');
|
||||
const trigger = el('channel-picker-btn');
|
||||
const search = el('channel-search');
|
||||
const closeBtn = el('channel-picker-close');
|
||||
if (!picker || !trigger) return;
|
||||
bound = true;
|
||||
|
||||
trigger.addEventListener('click', open);
|
||||
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||
picker.addEventListener('click', (event) => { if (event.target === picker) close(); });
|
||||
if (search) {
|
||||
search.addEventListener('input', render);
|
||||
search.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); step(1); }
|
||||
else if (event.key === 'ArrowUp') { event.preventDefault(); step(-1); }
|
||||
else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const button = matched[activeIndex];
|
||||
if (button) button.click();
|
||||
} else if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && picker.classList.contains('open')) close();
|
||||
});
|
||||
};
|
||||
|
||||
// The trigger says what is being read now, with the same favicon the
|
||||
// list shows, so the menu answers the question without being opened.
|
||||
const renderTrigger = function() {
|
||||
const session = App.storage.getSession();
|
||||
const channel = session && session.channel;
|
||||
const name = el('channel-trigger-name');
|
||||
const note = el('channel-trigger-note');
|
||||
const mark = el('channel-trigger-mark');
|
||||
const icon = el('channel-trigger-icon');
|
||||
if (!name || !mark || !icon) return;
|
||||
name.textContent = channel ? (channel.name || channel.id) : 'No channel';
|
||||
mark.dataset.letter = (channel ? (channel.name || channel.id || '?') : '?')
|
||||
.trim().charAt(0).toUpperCase();
|
||||
if (note) {
|
||||
note.textContent = channel
|
||||
? (channel.isGroup ? 'Whole group' : (channel.description || ''))
|
||||
: 'This source has no channels.';
|
||||
}
|
||||
App.videos.detachThumbnail(icon);
|
||||
icon.removeAttribute('src');
|
||||
icon.hidden = !(channel && channel.favicon);
|
||||
if (channel && channel.favicon) App.videos.attachThumbnail(icon, channel.favicon);
|
||||
};
|
||||
|
||||
return {
|
||||
// Called by renderMenu: the picker follows whatever the menu is
|
||||
// showing, and nothing else has to know it exists.
|
||||
render: function() {
|
||||
bind();
|
||||
build();
|
||||
renderTrigger();
|
||||
if (el('channel-picker') && el('channel-picker').classList.contains('open')) {
|
||||
render();
|
||||
}
|
||||
},
|
||||
open: open,
|
||||
close: close,
|
||||
// The flat list the command palette offers alongside its own
|
||||
// actions -- id and label only; it draws its own rows.
|
||||
entries: function() {
|
||||
if (!sections.length) build();
|
||||
const out = [];
|
||||
sections.forEach((section) => section.rows.forEach((row) => {
|
||||
out.push({ id: row.id, label: row.name, group: section.title });
|
||||
}));
|
||||
return out;
|
||||
},
|
||||
choose: function(id) {
|
||||
const session = App.storage.getSession();
|
||||
const data = activeServerData();
|
||||
const nextChannel = data ? App.session.resolveChannelById(data, id) : null;
|
||||
if (!session || !nextChannel) return;
|
||||
const serverPrefs = App.storage.getPreferences()[session.server] || {};
|
||||
const savedOptions = serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] : null;
|
||||
const nextSession = {
|
||||
server: session.server,
|
||||
channel: nextChannel,
|
||||
options: savedOptions ?
|
||||
App.session.hydrateOptions(nextChannel, savedOptions) :
|
||||
App.session.buildDefaultOptions(nextChannel)
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
close();
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// Settings + menu rendering.
|
||||
App.ui.renderMenu = function() {
|
||||
const session = App.storage.getSession();
|
||||
const serverEntries = App.storage.getServerEntries();
|
||||
const sourceSelect = document.getElementById('source-select');
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
const filtersContainer = document.getElementById('filters-container');
|
||||
const sourcesList = document.getElementById('sources-list');
|
||||
const addSourceBtn = document.getElementById('add-source-btn');
|
||||
@@ -242,7 +586,7 @@ App.ui = App.ui || {};
|
||||
const reloadChannelBtn = document.getElementById('reload-channel-btn');
|
||||
const favoritesToggle = document.getElementById('favorites-toggle');
|
||||
|
||||
if (!sourceSelect || !channelSelect || !filtersContainer) return;
|
||||
if (!sourceSelect || !filtersContainer) return;
|
||||
|
||||
sourceSelect.innerHTML = "";
|
||||
serverEntries.forEach((entry) => {
|
||||
@@ -281,80 +625,7 @@ App.ui = App.ui || {};
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
|
||||
const activeServerData = activeServer && activeServer.data ? activeServer.data : null;
|
||||
const availableChannels = activeServerData && activeServerData.channels ?
|
||||
[...activeServerData.channels] :
|
||||
[];
|
||||
availableChannels.sort((a, b) => {
|
||||
const nameA = (a.name || a.id || '').toLowerCase();
|
||||
const nameB = (b.name || b.id || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
const channelGroups = activeServerData && Array.isArray(activeServerData.channelGroups) ?
|
||||
activeServerData.channelGroups :
|
||||
[];
|
||||
|
||||
channelSelect.innerHTML = "";
|
||||
const groupedChannelIds = new Set();
|
||||
channelGroups.forEach((group) => {
|
||||
const channelIds = Array.isArray(group.channelIds) ?
|
||||
group.channelIds.filter((id) => availableChannels.some((channel) => channel.id === id)) :
|
||||
[];
|
||||
if (channelIds.length === 0) return;
|
||||
channelIds.forEach((id) => groupedChannelIds.add(id));
|
||||
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = group.title || group.id;
|
||||
|
||||
const groupOption = document.createElement('option');
|
||||
groupOption.value = `group:${group.id}`;
|
||||
groupOption.textContent = `All ${group.title || group.id}`;
|
||||
optgroup.appendChild(groupOption);
|
||||
|
||||
channelIds.forEach((id) => {
|
||||
const channel = availableChannels.find((ch) => ch.id === id);
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.id;
|
||||
option.textContent = channel.name || channel.id;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
|
||||
channelSelect.appendChild(optgroup);
|
||||
});
|
||||
|
||||
availableChannels
|
||||
.filter((channel) => !groupedChannelIds.has(channel.id))
|
||||
.forEach((channel) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.id;
|
||||
option.textContent = channel.name || channel.id;
|
||||
channelSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (session && session.channel) {
|
||||
channelSelect.value = session.channel.id;
|
||||
}
|
||||
|
||||
channelSelect.onchange = () => {
|
||||
const selectedId = channelSelect.value;
|
||||
const nextChannel = activeServerData ? App.session.resolveChannelById(activeServerData, selectedId) : null;
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[session.server] || {};
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: session.server,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
App.ui.channels.render();
|
||||
|
||||
App.ui.renderFilters(filtersContainer, session);
|
||||
|
||||
|
||||
@@ -114,10 +114,36 @@ App.videos = App.videos || {};
|
||||
return `${secs}`;
|
||||
};
|
||||
|
||||
App.videos.buildImageProxyUrl = function(imageUrl) {
|
||||
if (!imageUrl) return '';
|
||||
// A thumbnail URL we can actually fetch, or '' if the provider sent
|
||||
// something we can't use.
|
||||
//
|
||||
// Providers do send junk: sxyprn's "latest" listing carries items whose
|
||||
// `thumb` is the bare string "https:". Resolved against the page -- which
|
||||
// is what `new URL(url, location)` does with anything that isn't absolute
|
||||
// -- that becomes *our own* address, and the card then races our own HTML
|
||||
// as if it were a picture, pins our origin to the proxy for the rest of
|
||||
// the session, and asks /api/image to fetch "https:" (a 400, every time).
|
||||
// So an address that doesn't stand on its own is treated as no thumbnail
|
||||
// at all, which is what it is.
|
||||
const usableThumbUrl = function(url) {
|
||||
if (!url || typeof url !== 'string') return '';
|
||||
let parsed;
|
||||
try {
|
||||
return `/api/image?url=${encodeURIComponent(imageUrl)}`;
|
||||
parsed = new URL(url); // no base: relative input throws
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
|
||||
// The provider's own spelling, not the parsed one: a signed URL is only
|
||||
// valid as it was written, and normalising it can change the bytes.
|
||||
return url;
|
||||
};
|
||||
|
||||
App.videos.buildImageProxyUrl = function(imageUrl) {
|
||||
const target = usableThumbUrl(imageUrl);
|
||||
if (!target) return '';
|
||||
try {
|
||||
return `/api/image?url=${encodeURIComponent(target)}`;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
@@ -177,10 +203,11 @@ App.videos = App.videos || {};
|
||||
// provider while the host is still unknown -- the optimistic route, and the
|
||||
// one a race starts on anyway.
|
||||
App.videos.thumbnailUrl = function(url) {
|
||||
if (!url) return '';
|
||||
return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY
|
||||
? (App.videos.buildImageProxyUrl(url) || url)
|
||||
: url;
|
||||
const target = usableThumbUrl(url);
|
||||
if (!target) return '';
|
||||
return imageRoutes.get(imageHostOf(target)) === IMAGE_PROXY
|
||||
? (App.videos.buildImageProxyUrl(target) || target)
|
||||
: target;
|
||||
};
|
||||
|
||||
// A src-less <img> counts as "unavailable", and the browser paints its alt
|
||||
@@ -203,29 +230,76 @@ App.videos = App.videos || {};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
// Last resort on a route that normally works: one expired or missing image
|
||||
// shouldn't be left broken just because its host is fine in general.
|
||||
const attachProxyFallback = function(img, proxyUrl, token) {
|
||||
if (!proxyUrl) return;
|
||||
// What a thumbnail that fails is given next, in order.
|
||||
//
|
||||
// A failure used to mean one more attempt -- the proxy -- and then an empty
|
||||
// box for as long as the card stayed mounted. That is one blip away from a
|
||||
// grid with holes in it: a refused connection, our own server busy for a
|
||||
// moment, an origin that drops one request in twenty, and that card is
|
||||
// simply blank until the reader happens to scroll it out of the window and
|
||||
// back. So a failure now walks a short ladder instead: the other route
|
||||
// first (most failures are one route's fault, not the picture's), then both
|
||||
// again after a pause. Bounded and backed off, so a page of genuinely dead
|
||||
// images costs a handful of requests rather than a storm, and abandoned the
|
||||
// moment the card is rebound.
|
||||
const RETRY_PAUSE_MS = 900;
|
||||
|
||||
const retryPlan = function(directUrl, proxyUrl, route) {
|
||||
const own = route === IMAGE_PROXY ? proxyUrl : directUrl;
|
||||
const other = route === IMAGE_PROXY ? directUrl : proxyUrl;
|
||||
return [
|
||||
{ url: other, delay: 0 },
|
||||
{ url: own, delay: RETRY_PAUSE_MS },
|
||||
{ url: other, delay: RETRY_PAUSE_MS * 3 }
|
||||
].filter((step) => !!step.url);
|
||||
};
|
||||
|
||||
// Arms `img` with the steps to take if what it is showing fails to load.
|
||||
const attachRetry = function(img, plan, token) {
|
||||
// Checked here too, not just in showThumbnail: this *replaces* whatever
|
||||
// fallback the image currently has, so a call arriving for a generation
|
||||
// the element has moved past would take away the live one and leave a
|
||||
// dead one -- the recycled card's thumbnail would then have no fallback
|
||||
// at all if it failed.
|
||||
if (token !== undefined && img.dataset.thumbToken !== token) return;
|
||||
// the image is currently armed with, so a call arriving for a generation
|
||||
// the element has moved past would take away the live plan and leave a
|
||||
// dead one -- the recycled card's thumbnail would then have nothing
|
||||
// behind it if it failed.
|
||||
if (!img || (token !== undefined && img.dataset.thumbToken !== token)) return;
|
||||
detachRetry(img);
|
||||
if (!plan || !plan.length) return;
|
||||
const steps = plan.slice();
|
||||
// Held on the element so detachThumbnail can take it off again. On the
|
||||
// happy path it never fires and `once` never collects it, so a pooled
|
||||
// image would otherwise accumulate one closure per mount it has served.
|
||||
detachProxyFallback(img);
|
||||
const onError = () => { showThumbnail(img, proxyUrl, token); };
|
||||
img._thumbFallback = onError;
|
||||
const onError = function() {
|
||||
img._thumbRetry = null;
|
||||
const step = steps.shift();
|
||||
if (!step) return;
|
||||
const go = function() {
|
||||
img._thumbRetryTimer = null;
|
||||
if (token !== undefined && img.dataset.thumbToken !== token) return;
|
||||
// Re-arm before the src lands, so a step that fails immediately
|
||||
// (a cached refusal) still hands on to the next one.
|
||||
attachRetry(img, steps, token);
|
||||
// The same URL assigned to the same element is not a new src,
|
||||
// and a browser that sees no change starts no request -- so a
|
||||
// retry of the route we're already on has to clear it first.
|
||||
if (img.getAttribute('src') === step.url) img.removeAttribute('src');
|
||||
showThumbnail(img, step.url, token);
|
||||
};
|
||||
if (step.delay > 0) img._thumbRetryTimer = setTimeout(go, step.delay);
|
||||
else go();
|
||||
};
|
||||
img._thumbRetry = onError;
|
||||
img.addEventListener('error', onError, { once: true });
|
||||
};
|
||||
|
||||
const detachProxyFallback = function(img) {
|
||||
if (img && img._thumbFallback) {
|
||||
img.removeEventListener('error', img._thumbFallback);
|
||||
img._thumbFallback = null;
|
||||
const detachRetry = function(img) {
|
||||
if (!img) return;
|
||||
if (img._thumbRetry) {
|
||||
img.removeEventListener('error', img._thumbRetry);
|
||||
img._thumbRetry = null;
|
||||
}
|
||||
if (img._thumbRetryTimer) {
|
||||
clearTimeout(img._thumbRetryTimer);
|
||||
img._thumbRetryTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -238,12 +312,9 @@ App.videos = App.videos || {};
|
||||
if (!waiting) return;
|
||||
imageWaiting.delete(host);
|
||||
waiting.forEach((entry) => {
|
||||
if (route === IMAGE_PROXY) {
|
||||
showThumbnail(entry.img, entry.proxyUrl, entry.token);
|
||||
return;
|
||||
}
|
||||
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl, entry.token);
|
||||
showThumbnail(entry.img, entry.directUrl, entry.token);
|
||||
attachRetry(entry.img, retryPlan(entry.directUrl, entry.proxyUrl, route), entry.token);
|
||||
showThumbnail(entry.img,
|
||||
route === IMAGE_PROXY ? entry.proxyUrl : entry.directUrl, entry.token);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -303,12 +374,9 @@ App.videos = App.videos || {};
|
||||
const giveUp = function() {
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
if (imageRoutes.get(host) === IMAGE_PROXY) {
|
||||
showThumbnail(img, proxyUrl, token);
|
||||
return;
|
||||
}
|
||||
attachProxyFallback(img, proxyUrl, token);
|
||||
showThumbnail(img, directUrl, token);
|
||||
const route = imageRoutes.get(host);
|
||||
attachRetry(img, retryPlan(directUrl, proxyUrl, route), token);
|
||||
showThumbnail(img, route === IMAGE_PROXY ? proxyUrl : directUrl, token);
|
||||
};
|
||||
|
||||
const decide = function() {
|
||||
@@ -390,7 +458,9 @@ App.videos = App.videos || {};
|
||||
// Points `img` at `url` by whichever route is known to work for its host,
|
||||
// racing the two the first time that host is seen.
|
||||
App.videos.attachThumbnail = function(img, url) {
|
||||
const directUrl = url || (img && img.dataset.thumb) || '';
|
||||
// An address that isn't one is the same thing as no thumbnail: the card
|
||||
// keeps its placeholder rather than chasing it. See usableThumbUrl.
|
||||
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
|
||||
if (!img) return;
|
||||
// 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
|
||||
@@ -415,6 +485,7 @@ App.videos = App.videos || {};
|
||||
img.dataset.thumbToken = token;
|
||||
|
||||
if (route === IMAGE_PROXY) {
|
||||
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
|
||||
showThumbnail(img, proxyUrl || directUrl, token);
|
||||
return;
|
||||
}
|
||||
@@ -429,8 +500,8 @@ App.videos = App.videos || {};
|
||||
}
|
||||
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
|
||||
// Known good, or nothing to race against: take the provider and keep
|
||||
// the proxy as this image's own fallback.
|
||||
attachProxyFallback(img, proxyUrl, token);
|
||||
// the proxy behind it.
|
||||
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_DIRECT), token);
|
||||
showThumbnail(img, directUrl, token);
|
||||
return;
|
||||
}
|
||||
@@ -438,13 +509,13 @@ App.videos = App.videos || {};
|
||||
};
|
||||
|
||||
// Voids whatever is still in flight for this element's thumbnail. Its
|
||||
// generation moves on, so a race that settles later, or a proxy fallback
|
||||
// that fires later, finds a token that no longer matches and does nothing.
|
||||
// generation moves on, so a race that settles later, or a retry that fires
|
||||
// later, finds a token that no longer matches and does nothing.
|
||||
App.videos.detachThumbnail = function(img) {
|
||||
if (!img) return;
|
||||
img.dataset.thumbToken = String(++thumbSeq);
|
||||
delete img.dataset.alt;
|
||||
detachProxyFallback(img);
|
||||
detachRetry(img);
|
||||
};
|
||||
|
||||
// Each channel in a group sends back a different number of videos per
|
||||
@@ -1179,7 +1250,13 @@ App.videos = App.videos || {};
|
||||
let rafPending = false;
|
||||
let lastAnchor = null; // reader's place, refreshed on every scroll pass
|
||||
let heldAnchor = null; // anchor frozen while a resize settles
|
||||
const OVERSCAN = 1.2; // viewports of cards kept mounted off-screen
|
||||
// Viewports of cards kept mounted off-screen. Narrowing this during a
|
||||
// fling looks like an obvious saving and measures as the opposite: a
|
||||
// tight window means cards leave and re-enter it as the viewport moves,
|
||||
// and the remounting costs far more than the painting it avoids. Tried
|
||||
// at 0.25 -- mounts went from 88 to 155 and blocked time from 3.6s to
|
||||
// 4.3s. It stays generous on purpose.
|
||||
const OVERSCAN = 1.2;
|
||||
|
||||
const grid = () => document.getElementById('video-grid');
|
||||
|
||||
@@ -1290,6 +1367,10 @@ App.videos = App.videos || {};
|
||||
colBottoms[col] = top + h + gap;
|
||||
}
|
||||
setContainerHeight();
|
||||
// A page has just landed -- its JSON and thumbnails were prefetched,
|
||||
// so build its cards too while there is time, rather than when the
|
||||
// reader arrives at them.
|
||||
prepareAhead();
|
||||
};
|
||||
|
||||
// Replaces item i's estimated height with its real (post-image-load)
|
||||
@@ -1330,16 +1411,186 @@ App.videos = App.videos || {};
|
||||
const POOL_MAX = 60;
|
||||
let builtCards = 0;
|
||||
let recycledCards = 0;
|
||||
let preparedCards = 0;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Flinging
|
||||
//
|
||||
// A card that flies past costs far more than it costs to build. Its
|
||||
// thumbnail is a request, a decode and a height correction; its title
|
||||
// is a forced layout; its formats are a yt-dlp extraction on the
|
||||
// server. Every one of those is work for a video the reader has not
|
||||
// looked at, and during a fast scroll there are dozens of them.
|
||||
//
|
||||
// So a mount while flinging only *places* the card -- it takes its
|
||||
// space in the layout, at the right size, with its text. Everything
|
||||
// that costs is deferred to the moment the scroll settles, and only
|
||||
// for the cards still on screen by then. Anything scrolled past in the
|
||||
// meantime is unmounted having cost almost nothing.
|
||||
// ---------------------------------------------------------------
|
||||
const FLING_PX_PER_MS = 1.6; // ~1600px/s, well past a deliberate scroll
|
||||
const SETTLE_MS = 140;
|
||||
let flinging = false;
|
||||
let lastScrollY = 0;
|
||||
let lastScrollAt = 0;
|
||||
let settleTimer = null;
|
||||
const unfilled = new Set(); // mounted indices still waiting to be filled
|
||||
|
||||
const noteScroll = function() {
|
||||
const now = performance.now();
|
||||
const y = window.scrollY;
|
||||
const dt = now - lastScrollAt;
|
||||
if (dt > 0 && lastScrollAt) {
|
||||
flinging = (Math.abs(y - lastScrollY) / dt) > FLING_PX_PER_MS;
|
||||
}
|
||||
lastScrollY = y;
|
||||
lastScrollAt = now;
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(settle, SETTLE_MS);
|
||||
};
|
||||
|
||||
// DOM work cannot leave the main thread -- no worker can touch it -- so
|
||||
// "smooth" is not a matter of doing it elsewhere but of never holding
|
||||
// the thread long enough to miss a frame. Both queues below are
|
||||
// therefore drained against a budget: a few cards per frame, the rest
|
||||
// carried to the next one. Total work is unchanged; what changes is
|
||||
// that it stops arriving in one lump.
|
||||
const FRAME_BUDGET_MS = 4;
|
||||
|
||||
const settle = function() {
|
||||
settleTimer = null;
|
||||
flinging = false;
|
||||
drainFills();
|
||||
warmPool();
|
||||
prepareAhead();
|
||||
};
|
||||
|
||||
// Filling every deferred card at once -- which is what settling used to
|
||||
// do -- just moves the stall from during the fling to the end of it.
|
||||
const drainFills = function() {
|
||||
const started = performance.now();
|
||||
const queued = Array.from(unfilled);
|
||||
for (let k = 0; k < queued.length; k++) {
|
||||
if (performance.now() - started >= FRAME_BUDGET_MS) break;
|
||||
const i = queued[k];
|
||||
const card = mounted.get(i);
|
||||
if (card) fill(i, card, state.loadedVideos[i]);
|
||||
else unfilled.delete(i);
|
||||
}
|
||||
if (unfilled.size) requestAnimationFrame(drainFills);
|
||||
};
|
||||
|
||||
// Cards entering the window, mounted a few per frame. A fast scroll can
|
||||
// bring a dozen or more into range at once, and mounting them in one
|
||||
// callback is a frame missed no matter how cheap each card is.
|
||||
let mountQueue = [];
|
||||
|
||||
const drainMounts = function() {
|
||||
const started = performance.now();
|
||||
// One insertion for the whole frame's worth of cards rather than one
|
||||
// per card: the browser gets a single subtree to take in.
|
||||
const batch = document.createDocumentFragment();
|
||||
const placed = [];
|
||||
while (mountQueue.length && performance.now() - started < FRAME_BUDGET_MS) {
|
||||
const i = mountQueue.shift();
|
||||
const card = mount(i, batch);
|
||||
if (card) placed.push([i, card]);
|
||||
}
|
||||
if (placed.length) grid().appendChild(batch);
|
||||
// Filling reads layout, so it happens after the batch is in.
|
||||
placed.forEach(([i, card]) => {
|
||||
const v = state.loadedVideos[i];
|
||||
if (flinging) unfilled.add(i);
|
||||
else fill(i, card, v);
|
||||
});
|
||||
if (mountQueue.length) requestAnimationFrame(drainMounts);
|
||||
};
|
||||
|
||||
// Cards built ahead of being needed, while nothing else is happening,
|
||||
// so a mount during a scroll is always the cheap rebind and never the
|
||||
// expensive construction. The page already prefetches the next page's
|
||||
// JSON and thumbnails (see prefetchNextBatch); this is the same idea
|
||||
// applied to the DOM those items will need.
|
||||
const POOL_WARM_TARGET = 24;
|
||||
|
||||
const warmPool = function() {
|
||||
if (pool.length >= POOL_WARM_TARGET) return;
|
||||
idle(() => {
|
||||
const deadline = performance.now() + 3;
|
||||
while (pool.length < POOL_WARM_TARGET && performance.now() < deadline) {
|
||||
pool.push(createCardShell());
|
||||
}
|
||||
if (pool.length < POOL_WARM_TARGET) warmPool();
|
||||
});
|
||||
};
|
||||
|
||||
// Cards for videos just past the window, built and bound to their video
|
||||
// ahead of time. This is as close to "prepare the next page off-thread"
|
||||
// as a browser allows: the DOM itself can only ever be built on the main
|
||||
// thread, so the saving comes from doing it while nothing else is
|
||||
// happening rather than during the scroll that needs it.
|
||||
const PREPARE_AHEAD = 12;
|
||||
const prepared = new Map(); // video id -> card, ready to place
|
||||
|
||||
let preparing = false;
|
||||
|
||||
const prepareAhead = function() {
|
||||
if (preparing) return; // one idle pass at a time, not one per call
|
||||
preparing = true;
|
||||
idle(() => {
|
||||
preparing = false;
|
||||
const videos = state.loadedVideos;
|
||||
if (!videos.length) return;
|
||||
// Start from the last mounted index: the cards after it are the
|
||||
// ones the reader is heading towards.
|
||||
let highest = -1;
|
||||
mounted.forEach((card, i) => { if (i > highest) highest = i; });
|
||||
const deadline = performance.now() + 3;
|
||||
for (let i = highest + 1; i < videos.length && i <= highest + PREPARE_AHEAD; i++) {
|
||||
if (performance.now() >= deadline) break;
|
||||
const v = videos[i];
|
||||
if (!v || mounted.has(i) || prepared.has(String(v.id))) continue;
|
||||
const card = pool.pop() || createCardShell();
|
||||
App.videos.bindCard(card, v);
|
||||
prepared.set(String(v.id), card);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Hands back a card already bound to this video, if one was prepared.
|
||||
const takePrepared = function(v) {
|
||||
const key = String(v.id);
|
||||
const card = prepared.get(key);
|
||||
if (!card) return null;
|
||||
prepared.delete(key);
|
||||
// Favouriting may have happened since it was prepared, and the heart
|
||||
// is the one part of a card that changes without the video changing.
|
||||
App.favorites.setButtonState(cardRefs(card).favorite,
|
||||
!!App.favorites.getKey(v) && App.favorites.has(v));
|
||||
return card;
|
||||
};
|
||||
|
||||
const dropPrepared = function() {
|
||||
prepared.forEach((card) => {
|
||||
App.videos.resetCard(card);
|
||||
if (pool.length < POOL_MAX) pool.push(card);
|
||||
});
|
||||
prepared.clear();
|
||||
};
|
||||
|
||||
const acquire = function(v) {
|
||||
const ready = takePrepared(v);
|
||||
if (ready) {
|
||||
preparedCards++;
|
||||
return ready;
|
||||
}
|
||||
const card = pool.pop();
|
||||
if (!card) {
|
||||
builtCards++;
|
||||
return App.videos.buildCard(v);
|
||||
return App.videos.buildCard(v, { skipThumbnail: true });
|
||||
}
|
||||
recycledCards++;
|
||||
App.videos.bindCard(card, v);
|
||||
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
||||
return card;
|
||||
};
|
||||
|
||||
@@ -1359,11 +1610,11 @@ App.videos = App.videos || {};
|
||||
if (pool.length < POOL_MAX) pool.push(card);
|
||||
};
|
||||
|
||||
const mount = function(i) {
|
||||
if (mounted.has(i)) return;
|
||||
const mount = function(i, batch) {
|
||||
if (mounted.has(i)) return null;
|
||||
const v = state.loadedVideos[i];
|
||||
const l = layout[i];
|
||||
if (!v || !l) return;
|
||||
if (!v || !l) return null;
|
||||
const card = acquire(v);
|
||||
place(card, l);
|
||||
// Entrance animation only the first time an index appears, so cards
|
||||
@@ -1378,11 +1629,23 @@ App.videos = App.videos || {};
|
||||
card._revealAnim = onRevealEnd;
|
||||
card.addEventListener('animationend', onRevealEnd, { once: true });
|
||||
}
|
||||
grid().appendChild(card);
|
||||
(batch || grid()).appendChild(card);
|
||||
mounted.set(i, card);
|
||||
if (batch) return card; // the caller fills, once the batch is in
|
||||
if (flinging) unfilled.add(i);
|
||||
else fill(i, card, v);
|
||||
return card;
|
||||
};
|
||||
|
||||
// The part of a mount that costs: the thumbnail, the title measurement
|
||||
// and the format resolve the observer triggers.
|
||||
const fill = function(i, card, v) {
|
||||
if (!card || !v) return;
|
||||
unfilled.delete(i);
|
||||
const img = cardRefs(card).img;
|
||||
App.videos.attachThumbnail(img, v.thumb);
|
||||
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
||||
// its true aspect ratio, clear the shimmer, then correct the height.
|
||||
const img = card.querySelector('img');
|
||||
if (img) {
|
||||
const reveal = () => {
|
||||
// Release nulls this, so a reveal already queued for a frame
|
||||
@@ -1416,6 +1679,7 @@ App.videos = App.videos || {};
|
||||
const card = mounted.get(i);
|
||||
if (!card) return;
|
||||
mounted.delete(i);
|
||||
unfilled.delete(i);
|
||||
release(card);
|
||||
};
|
||||
|
||||
@@ -1453,7 +1717,10 @@ App.videos = App.videos || {};
|
||||
anchorIndex = i;
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < entering.length; k++) mount(entering[k]);
|
||||
// Unmounting already happened above, so the pool is stocked before
|
||||
// anything asks it for a card.
|
||||
mountQueue = entering;
|
||||
drainMounts();
|
||||
// Frozen while a resize settles: the browser moves the scroll
|
||||
// position itself during a rotation, and tracking that would replace
|
||||
// the reader's real place with wherever the browser landed.
|
||||
@@ -1485,6 +1752,11 @@ App.videos = App.videos || {};
|
||||
return tailTop !== Infinity && viewTop + vh >= tailTop;
|
||||
};
|
||||
|
||||
const onScroll = function() {
|
||||
noteScroll();
|
||||
scheduleUpdate();
|
||||
};
|
||||
|
||||
const scheduleUpdate = function() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
@@ -1601,7 +1873,8 @@ App.videos = App.videos || {};
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
settleWidth = window.innerWidth || 0;
|
||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||
warmPool();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
// orientationchange fires first (before the viewport metrics change),
|
||||
// which is exactly when the pre-rotation anchor is still valid.
|
||||
window.addEventListener('orientationchange', beginSettle);
|
||||
@@ -1620,6 +1893,10 @@ App.videos = App.videos || {};
|
||||
// its own cards, and these are exactly the right shape for it.
|
||||
mounted.forEach((card, i) => unmount(i));
|
||||
mounted.clear();
|
||||
unfilled.clear();
|
||||
dropPrepared();
|
||||
mountQueue = [];
|
||||
flinging = false;
|
||||
revealed.clear(); // new result set should animate in again
|
||||
layout.length = 0;
|
||||
colBottoms = [];
|
||||
@@ -1641,7 +1918,9 @@ App.videos = App.videos || {};
|
||||
};
|
||||
|
||||
const stats = function() {
|
||||
return { built: builtCards, recycled: recycledCards, pooled: pool.length };
|
||||
return { built: builtCards, recycled: recycledCards, prepared: preparedCards,
|
||||
pooled: pool.length, readied: prepared.size,
|
||||
unfilled: unfilled.size, flinging: flinging };
|
||||
};
|
||||
|
||||
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd, stats };
|
||||
@@ -1722,7 +2001,34 @@ App.videos = App.videos || {};
|
||||
// list is preferred (start with the last one). When a preferred height is
|
||||
// set, formats at or below it come first (best of those first), followed by
|
||||
// anything above it ordered closest-to-preferred first as a last resort.
|
||||
App.videos.rankFormats = function(formats, preferredHeight) {
|
||||
// How expensive a format is to decode, beyond its pixel count. Lower is
|
||||
// cheaper, and these are ordered by how much they actually cost:
|
||||
//
|
||||
// HLS means hls.js demuxes every segment in JavaScript before the browser
|
||||
// sees it. A progressive file skips that entirely -- and with several
|
||||
// panels each running their own instance, it is the difference between
|
||||
// one media pipeline and four.
|
||||
//
|
||||
// AV1 and VP9 are frequently decoded in software; H.264 has hardware
|
||||
// support almost everywhere. That is a cliff, not a gradient.
|
||||
//
|
||||
// 60fps is twice the frames of 30fps for the same picture.
|
||||
const decodeCost = function(fmt) {
|
||||
const protocol = String(fmt.protocol || '').toLowerCase();
|
||||
const ext = String(fmt.ext || '').toLowerCase();
|
||||
const vcodec = String(fmt.vcodec || '').toLowerCase();
|
||||
const streaming = protocol.indexOf('m3u8') >= 0 || protocol.indexOf('dash') >= 0
|
||||
|| ext === 'm3u8' || ext === 'mpd' ? 1 : 0;
|
||||
const software = (vcodec.indexOf('av01') === 0 || vcodec.indexOf('vp9') === 0
|
||||
|| vcodec.indexOf('vp09') === 0) ? 1 : 0;
|
||||
const highFps = App.videos.coerceNumber(fmt.fps) > 35 ? 1 : 0;
|
||||
return streaming * 4 + software * 2 + highFps;
|
||||
};
|
||||
|
||||
// `options.cheapest` ranks by what a machine has to do to play the format,
|
||||
// once the height ceiling has been applied. It is for showing several
|
||||
// videos at once, where the limit is the decoder rather than the picture.
|
||||
App.videos.rankFormats = function(formats, preferredHeight, options) {
|
||||
if (!Array.isArray(formats) || formats.length === 0) return [];
|
||||
const candidates = formats
|
||||
.map((fmt, index) => ({ fmt, index }))
|
||||
@@ -1736,12 +2042,17 @@ App.videos = App.videos || {};
|
||||
return false;
|
||||
});
|
||||
const pool = videoCandidates.length ? videoCandidates : candidates;
|
||||
const cheapest = !!(options && options.cheapest);
|
||||
const score = (fmt) => {
|
||||
const height = App.videos.coerceNumber(fmt.height || fmt.quality);
|
||||
const width = App.videos.coerceNumber(fmt.width);
|
||||
const size = height || width;
|
||||
const bitrate = App.videos.coerceNumber(fmt.tbr || fmt.bitrate);
|
||||
const fps = App.videos.coerceNumber(fmt.fps);
|
||||
// Cost is negated so that the same descending comparison puts the
|
||||
// cheaper format first, and it outranks bitrate: a slightly softer
|
||||
// picture that plays is worth more than a sharper one that stutters.
|
||||
if (cheapest) return [size, -decodeCost(fmt), bitrate, fps];
|
||||
return [size, bitrate, fps];
|
||||
};
|
||||
// Tie-break on the original index so equal-quality formats start with
|
||||
@@ -1795,9 +2106,18 @@ App.videos = App.videos || {};
|
||||
if (applyPreferredQuality) {
|
||||
const preferredQuality = App.storage.getPreferredQuality();
|
||||
preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality);
|
||||
// A caller showing this in a fraction of the screen -- a split reels
|
||||
// panel -- caps it further. Decoding a 1080p stream into a quarter
|
||||
// of a phone screen costs the same as decoding it full size, and
|
||||
// four of those at once is what makes a split view stutter.
|
||||
const maxHeight = App.videos.coerceNumber(options && options.maxHeight);
|
||||
if (maxHeight > 0) {
|
||||
preferredHeight = preferredHeight ? Math.min(preferredHeight, maxHeight) : maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const sources = App.videos.rankFormats(meta.formats, preferredHeight).map((fmt) => {
|
||||
const ranking = { cheapest: !!(options && options.cheapest) };
|
||||
const sources = App.videos.rankFormats(meta.formats, preferredHeight, ranking).map((fmt) => {
|
||||
// An *explicit* Referer (from the extractor) signals the upstream
|
||||
// enforces it; deriveReferer is only a best-effort fallback. The
|
||||
// browser can't set a cross-origin Referer, so refererRequired tells
|
||||
|
||||
199
tests/smoke_channels.py
Normal file
199
tests/smoke_channels.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Channel picker smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_channels.py
|
||||
|
||||
The picker replaced a <select>, so what's checked here is what the <select>
|
||||
couldn't do -- show what a channel *is* (favicon, description, tags, group) and
|
||||
let it be searched -- plus the two things it could: switching the channel, and
|
||||
feeding the command palette its list.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottub.spacemoehre.de"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
ROWS = """() => {
|
||||
const list = document.getElementById('channel-picker-list');
|
||||
const rows = Array.from(list.querySelectorAll('.channel-row'));
|
||||
return {
|
||||
sections: Array.from(list.querySelectorAll('.channel-section')).map((s) => s.textContent),
|
||||
count: rows.length,
|
||||
withNote: rows.filter((r) => !!r.querySelector('.channel-row-note')).length,
|
||||
withTags: rows.filter((r) => !!r.querySelector('.channel-tag')).length,
|
||||
withIconEl: rows.filter((r) => !!r.querySelector('img.channel-favicon')).length,
|
||||
iconsLoaded: rows.filter((r) => {
|
||||
const img = r.querySelector('img.channel-favicon');
|
||||
return img && img.naturalWidth > 0;
|
||||
}).length,
|
||||
current: rows.filter((r) => r.classList.contains('is-current'))
|
||||
.map((r) => r.dataset.channelId),
|
||||
groupRows: rows.filter((r) => r.dataset.channelId.startsWith('group:')).length,
|
||||
emptyHidden: document.getElementById('channel-picker-empty').hidden,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def boot(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
boot(page)
|
||||
|
||||
print("\nthe menu says which channel is being read")
|
||||
page.evaluate("() => App.ui.toggleDrawer('menu')")
|
||||
page.wait_for_timeout(2500)
|
||||
trigger = page.evaluate("""() => ({
|
||||
name: document.getElementById('channel-trigger-name').textContent,
|
||||
note: document.getElementById('channel-trigger-note').textContent,
|
||||
iconLoaded: document.getElementById('channel-trigger-icon').naturalWidth > 0,
|
||||
})""")
|
||||
c.ok("the trigger names the current channel", trigger["name"] == "XVideos", str(trigger))
|
||||
c.ok("and says something about it", len(trigger["note"]) > 10, str(trigger))
|
||||
c.ok("and shows its favicon", trigger["iconLoaded"], str(trigger))
|
||||
|
||||
print("\nopening it lists the channels with what the server knows")
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(3000)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("every channel is listed", rows["count"] > 50, str(rows["count"]))
|
||||
c.ok("grouped under the server's own headings", len(rows["sections"]) > 1,
|
||||
str(rows["sections"][:4]))
|
||||
c.ok("each group can be browsed whole", rows["groupRows"] > 1, str(rows["groupRows"]))
|
||||
c.ok("rows carry a description", rows["withNote"] > rows["count"] // 2, str(rows))
|
||||
c.ok("rows carry tags", rows["withTags"] > rows["count"] // 2, str(rows))
|
||||
c.ok("rows carry a favicon", rows["withIconEl"] > rows["count"] // 2, str(rows))
|
||||
c.ok("and the favicons actually load", rows["iconsLoaded"] > 10,
|
||||
f"{rows['iconsLoaded']} of {rows['withIconEl']} loaded")
|
||||
c.ok("the current channel is marked", rows["current"] == [CHANNEL], str(rows["current"]))
|
||||
|
||||
print("\nsearching narrows it")
|
||||
page.fill("#channel-search", "hentai")
|
||||
page.wait_for_timeout(600)
|
||||
found = page.evaluate(ROWS)
|
||||
c.ok("fewer rows than before", 0 < found["count"] < rows["count"], str(found["count"]))
|
||||
# A row can match on its own text or on the group it sits under -- the
|
||||
# heading is as much a fact about the channel as its description.
|
||||
c.ok("every row left standing matches what was typed",
|
||||
page.evaluate("""() => {
|
||||
let heading = '';
|
||||
return Array.from(document.getElementById('channel-picker-list').children)
|
||||
.every((node) => {
|
||||
if (node.classList.contains('channel-section')) {
|
||||
heading = node.textContent.toLowerCase();
|
||||
return true;
|
||||
}
|
||||
const text = (node.textContent + ' ' + node.dataset.channelId + ' ' + heading);
|
||||
return text.toLowerCase().includes('hentai');
|
||||
});
|
||||
}"""))
|
||||
# Description and tags are searched too, not just the name.
|
||||
page.fill("#channel-search", "leaks")
|
||||
page.wait_for_timeout(600)
|
||||
by_tag = page.evaluate(ROWS)
|
||||
c.ok("a tag finds channels whose name doesn't say it", by_tag["count"] > 0,
|
||||
str(by_tag["count"]))
|
||||
|
||||
page.fill("#channel-search", "zzzznothing")
|
||||
page.wait_for_timeout(600)
|
||||
nothing = page.evaluate(ROWS)
|
||||
c.ok("a search with no hits says so",
|
||||
nothing["count"] == 0 and not nothing["emptyHidden"], str(nothing))
|
||||
|
||||
print("\npicking one switches the feed")
|
||||
page.fill("#channel-search", "eporner")
|
||||
page.wait_for_timeout(600)
|
||||
page.click(".channel-row")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("the picker closes",
|
||||
page.evaluate("() => !document.getElementById('channel-picker').classList.contains('open')"))
|
||||
session = page.evaluate("() => App.storage.getSession().channel.id")
|
||||
c.ok("the session moved to it", session == "eporner", session)
|
||||
c.ok("the trigger followed",
|
||||
page.evaluate("() => document.getElementById('channel-trigger-name').textContent") == "EPorner")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
c.ok("and the grid reloaded",
|
||||
page.evaluate("() => App.state.loadedVideos.length > 0"))
|
||||
|
||||
print("\nthe keyboard works too")
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(1200)
|
||||
page.fill("#channel-search", "beeg")
|
||||
page.wait_for_timeout(500)
|
||||
page.press("#channel-search", "ArrowDown")
|
||||
page.press("#channel-search", "ArrowUp")
|
||||
page.press("#channel-search", "Enter")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("Enter picks the highlighted row",
|
||||
page.evaluate("() => App.storage.getSession().channel.id") == "beeg",
|
||||
page.evaluate("() => App.storage.getSession().channel.id"))
|
||||
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(800)
|
||||
page.press("#channel-search", "Escape")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("Escape closes it without changing anything",
|
||||
page.evaluate("""() => !document.getElementById('channel-picker').classList.contains('open')
|
||||
&& App.storage.getSession().channel.id === 'beeg'"""))
|
||||
|
||||
print("\nthe command palette still offers the channels")
|
||||
entries = page.evaluate("() => App.ui.channels.entries().length")
|
||||
c.ok("the picker hands out its list", entries > 50, str(entries))
|
||||
page.evaluate("() => App.enhance.openPalette()")
|
||||
page.wait_for_timeout(400)
|
||||
page.fill("#cmdk-input", "Redtube")
|
||||
page.wait_for_timeout(400)
|
||||
page.press("#cmdk-input", "Enter")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("and choosing one from there switches the channel",
|
||||
page.evaluate("() => App.storage.getSession().channel.id") == "redtube",
|
||||
page.evaluate("() => App.storage.getSession().channel.id"))
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
175
tests/smoke_docpip.py
Normal file
175
tests/smoke_docpip.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Document picture-in-picture: the reel, in a window, scrolling.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_docpip.py
|
||||
|
||||
A video picture-in-picture window renders one <video>'s frames and cannot
|
||||
scroll. Document picture-in-picture opens a real document instead, so the feed
|
||||
is *moved* into it -- the same elements, another window. That is what these
|
||||
checks are about: the move must be a move (no rebuild, playback intact), the
|
||||
window must scroll the reel for real, and closing it must put the feed back
|
||||
where it came from.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# The feed can be read from either document. Only App.state lives in the page --
|
||||
# the window's document has no scripts of its own; it holds the moved elements,
|
||||
# whose handlers are still the page's closures. That is the whole design, so the
|
||||
# probe takes the document to look in and always runs in the page.
|
||||
PROBE = """(where) => {
|
||||
const doc = where === 'pip'
|
||||
? (documentPictureInPicture.window && documentPictureInPicture.window.document)
|
||||
: document;
|
||||
if (!doc) return null;
|
||||
const root = doc.getElementById('feed-view');
|
||||
const active = doc.querySelector('.feed-slide.is-active');
|
||||
const scroller = doc.getElementById('feed-scroll');
|
||||
return {
|
||||
rooted_here: !!root,
|
||||
slides: doc.querySelectorAll('.feed-slide').length,
|
||||
step: App.state.feedActiveIndex,
|
||||
video: App.state.feedActiveVideoId,
|
||||
playing: active ? Array.from(active.querySelectorAll('.feed-video'))
|
||||
.map(v => !v.paused && v.readyState >= 2) : null,
|
||||
scrollable: scroller ? scroller.scrollHeight > scroller.clientHeight + 1 : false,
|
||||
unique: (() => {
|
||||
const ids = Array.from(doc.querySelectorAll('.feed-pane')).map(p => p.dataset.videoId);
|
||||
return ids.length === new Set(ids).size;
|
||||
})(),
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 1000})
|
||||
page = context.new_page()
|
||||
open_reels(page)
|
||||
|
||||
c.ok("this browser offers document picture-in-picture",
|
||||
page.evaluate("() => App.feed.docPipSupported()"))
|
||||
|
||||
before = page.evaluate(PROBE, 'page')
|
||||
c.ok("the feed starts in the page", before["rooted_here"])
|
||||
c.ok("and is scrollable there", before["scrollable"])
|
||||
|
||||
print("\nthe picture-in-picture button opens a window")
|
||||
# A click, not an evaluate: requestWindow needs a user gesture, which is
|
||||
# exactly the reason the tab-switch path cannot use this API.
|
||||
with context.expect_page(timeout=15000) as caught:
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
pip = caught.value
|
||||
pip.wait_for_timeout(3000)
|
||||
|
||||
c.ok("the feed reports a window open", page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed left the page", not page.evaluate(
|
||||
"() => !!document.getElementById('feed-view')"))
|
||||
c.ok("the page underneath is usable again", page.evaluate(
|
||||
"() => document.body.style.overflow === 'auto'"))
|
||||
|
||||
moved = page.evaluate(PROBE, 'pip')
|
||||
c.ok("the feed is in the window", moved["rooted_here"], str(moved))
|
||||
c.ok("its slides came with it", moved["slides"] > 0, str(moved["slides"]))
|
||||
# The point of moving rather than rebuilding: the <video> elements are
|
||||
# the same ones, so nothing reloads and nothing stops.
|
||||
c.ok("playback survived the move", moved["playing"] and all(moved["playing"]),
|
||||
str(moved["playing"]))
|
||||
c.ok("it landed on the same video", moved["video"] == before["video"],
|
||||
f"{before['video']} -> {moved['video']}")
|
||||
|
||||
print("\nthe window scrolls the reel")
|
||||
c.ok("the window's feed is scrollable", moved["scrollable"], str(moved))
|
||||
pip.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
pip.wait_for_timeout(3500)
|
||||
scrolled = page.evaluate(PROBE, 'pip')
|
||||
c.ok("scrolling moved to the next step", scrolled["step"] == moved["step"] + 1,
|
||||
f"{moved['step']} -> {scrolled['step']}")
|
||||
c.ok("and to another video", scrolled["video"] != moved["video"],
|
||||
f"{moved['video']} -> {scrolled['video']}")
|
||||
c.ok("the new step is playing", scrolled["playing"] and all(scrolled["playing"]),
|
||||
str(scrolled["playing"]))
|
||||
|
||||
print("\nclosing the window brings the feed home")
|
||||
pip.close()
|
||||
page.wait_for_timeout(3500)
|
||||
back = page.evaluate(PROBE, 'page')
|
||||
c.ok("no window is open", not page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed is in the page again", back["rooted_here"], str(back))
|
||||
c.ok("reels is still open", page.evaluate("() => App.feed.isOpen()"))
|
||||
c.ok("the page is back in reels mode", page.evaluate(
|
||||
"() => document.body.classList.contains('feed-mode-open')"))
|
||||
# The window was scrolled while it was out; coming back must not undo it.
|
||||
c.ok("it kept where the window left off", back["video"] == scrolled["video"],
|
||||
f"{scrolled['video']} -> {back['video']}")
|
||||
c.ok("the feed still scrolls here", back["scrollable"], str(back))
|
||||
c.ok("no video was duplicated by the round trip", back["unique"])
|
||||
|
||||
print("\nleaving reels while the window is open")
|
||||
with context.expect_page(timeout=15000) as caught2:
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
pip2 = caught2.value
|
||||
pip2.wait_for_timeout(2500)
|
||||
page.evaluate("() => App.feed.close()")
|
||||
page.wait_for_timeout(2000)
|
||||
c.ok("closing reels closes the window", pip2.is_closed() or
|
||||
not page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed came back before it was torn down", page.evaluate(
|
||||
"() => !!document.getElementById('feed-view')"))
|
||||
c.ok("the page is scrollable again", page.evaluate(
|
||||
"() => document.body.style.overflow === 'auto'"))
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -170,7 +170,16 @@ def check_cards(c, cards, phase):
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||
# Lean launch flags: this runs alongside the app's own server, and a
|
||||
# default Chromium spikes hard enough at startup to get itself killed on
|
||||
# a constrained box.
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
boot(page)
|
||||
|
||||
@@ -220,13 +229,19 @@ def main():
|
||||
() => (App.virtualGrid.stats && App.virtualGrid.stats()) || null
|
||||
""")
|
||||
if stats:
|
||||
total = (stats.get("built", 0) + stats.get("recycled", 0)) or 1
|
||||
rate = 100 * stats.get("recycled", 0) // total
|
||||
print(f"\npool: {stats.get('recycled', 0)} recycled / {total} mounts "
|
||||
f"({rate}% hit rate), {stats.get('pooled', 0)} idle in pool")
|
||||
# A hit rate near zero means cards are still being built per mount,
|
||||
# so none of the checks above actually exercised a recycled card.
|
||||
c.ok("cards are actually being recycled", rate >= 50, f"{rate}% hit rate")
|
||||
built = stats.get("built", 0)
|
||||
recycled = stats.get("recycled", 0)
|
||||
readymade = stats.get("prepared", 0)
|
||||
total = (built + recycled + readymade) or 1
|
||||
reused = 100 * (recycled + readymade) // total
|
||||
print(f"\nmounts: {total} -- {built} built, {recycled} recycled, "
|
||||
f"{readymade} prepared ahead; {stats.get('pooled', 0)} idle in pool, "
|
||||
f"{stats.get('readied', 0)} still readied")
|
||||
# If almost everything is still built per mount, none of the checks
|
||||
# above actually exercised a reused card.
|
||||
c.ok("cards are reused rather than rebuilt", reused >= 50, f"{reused}% reused")
|
||||
c.ok("some cards were prepared before they were needed", readymade > 0,
|
||||
"prepare-ahead never served a mount")
|
||||
|
||||
browser.close()
|
||||
|
||||
|
||||
170
tests/smoke_ios_pip.py
Normal file
170
tests/smoke_ios_pip.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Picture-in-picture on iOS, where the standard API does not exist.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_ios_pip.py
|
||||
|
||||
Safari on iPhone and iPad never implemented requestPictureInPicture. It has
|
||||
picture-in-picture -- it just reaches it through WebKit's older
|
||||
presentation-mode switch, and document.pictureInPictureEnabled is undefined,
|
||||
so every capability check answered "no" and the button was hidden on the one
|
||||
platform where people most want it.
|
||||
|
||||
There is no iPhone here, so the browser is reshaped to have iOS's API surface
|
||||
instead: the standard entry points are deleted and WebKit's are installed. That
|
||||
is enough to test what actually broke, because what broke was which API the
|
||||
code reaches for -- not what the browser does once it is called.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# Runs before any page script, so the app only ever sees the iOS shape.
|
||||
AS_IOS = """(() => {
|
||||
delete Document.prototype.pictureInPictureEnabled;
|
||||
delete Document.prototype.pictureInPictureElement;
|
||||
delete Document.prototype.exitPictureInPicture;
|
||||
delete HTMLVideoElement.prototype.requestPictureInPicture;
|
||||
delete HTMLVideoElement.prototype.disablePictureInPicture;
|
||||
delete window.documentPictureInPicture;
|
||||
|
||||
window.__pipCalls = [];
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'webkitPresentationMode', {
|
||||
configurable: true,
|
||||
get() { return this.__mode || 'inline'; },
|
||||
});
|
||||
HTMLVideoElement.prototype.webkitSupportsPresentationMode = function() { return true; };
|
||||
HTMLVideoElement.prototype.webkitSetPresentationMode = function(mode) {
|
||||
window.__pipCalls.push(mode);
|
||||
this.__mode = mode;
|
||||
// WebKit's event, which notably does not bubble.
|
||||
this.dispatchEvent(new Event('webkitpresentationmodechanged'));
|
||||
};
|
||||
})();"""
|
||||
|
||||
PINNED = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
return {
|
||||
calls: window.__pipCalls.slice(),
|
||||
pinned: !!(slide && App.feed.pipPinned(slide)),
|
||||
modes: Array.from(document.querySelectorAll('.feed-video'))
|
||||
.filter(v => v.webkitPresentationMode === 'picture-in-picture').length,
|
||||
video: App.state.feedActiveVideoId,
|
||||
paneVideo: slide ? slide.querySelector('.feed-pane').dataset.videoId : null,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 430, "height": 930})
|
||||
page.add_init_script(AS_IOS)
|
||||
open_reels(page)
|
||||
|
||||
print("\nthe capability check")
|
||||
c.ok("the standard API really is gone", page.evaluate(
|
||||
"() => !document.pictureInPictureEnabled && !window.documentPictureInPicture"))
|
||||
c.ok("picture-in-picture is still reported as available",
|
||||
page.evaluate("() => App.customPlayer.supportsPiP()"))
|
||||
c.ok("the button is not hidden", page.evaluate(
|
||||
"""() => { const b = document.querySelector('.feed-slide.is-active .feed-pip-btn');
|
||||
return !!b && !b.hidden; }"""))
|
||||
|
||||
print("\nthe button asks WebKit for it")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
entered = page.evaluate(PINNED)
|
||||
c.ok("it called webkitSetPresentationMode",
|
||||
entered["calls"] == ["picture-in-picture"], str(entered["calls"]))
|
||||
c.ok("exactly one video went to the window", entered["modes"] == 1, str(entered["modes"]))
|
||||
# WebKit's event does not bubble, so the feed's document-level listener
|
||||
# only hears about this if the adapter re-fires it.
|
||||
c.ok("the feed noticed and pinned the pane", entered["pinned"], str(entered))
|
||||
|
||||
print("\nmoving through the reel from the window")
|
||||
page.evaluate("() => App.feed.pipStep(1)")
|
||||
page.wait_for_timeout(3000)
|
||||
stepped = page.evaluate(PINNED)
|
||||
c.ok("the pinned pane moved to another video",
|
||||
stepped["paneVideo"] != entered["paneVideo"],
|
||||
f"{entered['paneVideo']} -> {stepped['paneVideo']}")
|
||||
c.ok("it is still the video in the window", stepped["pinned"], str(stepped))
|
||||
c.ok("and still only one", stepped["modes"] == 1, str(stepped["modes"]))
|
||||
|
||||
print("\nleaving the window")
|
||||
# What iOS does when the reader taps the window's close control.
|
||||
page.evaluate("""() => document.querySelectorAll('.feed-video').forEach((v) => {
|
||||
if (v.webkitPresentationMode === 'picture-in-picture') v.webkitSetPresentationMode('inline');
|
||||
})""")
|
||||
page.wait_for_timeout(3000)
|
||||
left = page.evaluate(PINNED)
|
||||
c.ok("the pin was released", not left["pinned"], str(left))
|
||||
c.ok("nothing is left in a window", left["modes"] == 0, str(left["modes"]))
|
||||
c.ok("the feed landed on the video the window ended on",
|
||||
left["video"] == stepped["paneVideo"],
|
||||
f"{stepped['paneVideo']} -> {left['video']}")
|
||||
c.ok("no video is shown twice after the rebuild", page.evaluate(
|
||||
"""() => { const ids = Array.from(document.querySelectorAll('.feed-pane'))
|
||||
.map(p => p.dataset.videoId);
|
||||
return ids.length === new Set(ids).size; }"""))
|
||||
|
||||
print("\nthe button toggles back off")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
toggled = page.evaluate(PINNED)
|
||||
c.ok("the second press asked to go back inline",
|
||||
toggled["calls"][-1] == "inline", str(toggled["calls"]))
|
||||
c.ok("nothing is left in a window", toggled["modes"] == 0, str(toggled["modes"]))
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
176
tests/smoke_reels.py
Executable file
176
tests/smoke_reels.py
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reels split-panel smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_reels.py
|
||||
|
||||
The load-bearing check is the preload guarantee: every panel must have its
|
||||
next video buffered before the reader swipes. With locked scrolling that is
|
||||
the same panel position in the next step, so it holds only while the next
|
||||
step is both built and preloaded for all of its panes -- which in turn rests
|
||||
on the floor of one step in windowBounds() and in setActive()'s preloadAhead.
|
||||
Both shrink as panes are added, and without the floor a wide split would
|
||||
leave panels with nothing to swipe to.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
LAYOUT = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
if (!slide) return null;
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
const bounds = slide.getBoundingClientRect();
|
||||
return {
|
||||
count: panes.length,
|
||||
reported: App.feed.paneCount(),
|
||||
videos: panes.map(p => p.dataset.videoId),
|
||||
// Controls positioned for a full viewport end up outside a short pane,
|
||||
// clipped by its overflow and unreachable.
|
||||
escaping: panes.reduce((bad, p, i) => {
|
||||
const pr = p.getBoundingClientRect();
|
||||
['.feed-fav-btn', '.feed-pip-btn', '.feed-format-btn', '.feed-pane-tools']
|
||||
.forEach((sel) => {
|
||||
const el = p.querySelector(sel);
|
||||
if (!el || el.hidden) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top < pr.top - 1 || r.bottom > pr.bottom + 1 ||
|
||||
r.left < pr.left - 1 || r.right > pr.right + 1) bad.push(i + sel);
|
||||
});
|
||||
return bad;
|
||||
}, []),
|
||||
within_slide: panes.every(p => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1;
|
||||
}),
|
||||
};
|
||||
}"""
|
||||
|
||||
PRELOAD = """() => {
|
||||
const active = App.state.feedActiveIndex;
|
||||
const rows = {};
|
||||
document.querySelectorAll('.feed-slide').forEach((slide) => {
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
rows[Number(slide.dataset.step) - active] = panes.map((p) => {
|
||||
const v = p.querySelector('.feed-video');
|
||||
return !!(v && (v.getAttribute('src') || v._hlsPlayer));
|
||||
});
|
||||
});
|
||||
return { per: App.feed.paneCount(), next: rows[1] || null, current: rows[0] || null };
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def split(page, selector):
|
||||
page.click(".feed-slide.is-active " + selector)
|
||||
page.wait_for_timeout(4000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
open_reels(page)
|
||||
|
||||
print("\nsingle panel")
|
||||
one = page.evaluate(LAYOUT)
|
||||
c.ok("reels opens with one panel", one and one["count"] == 1, str(one))
|
||||
|
||||
print("\nsplit right, then split the new panel below")
|
||||
split(page, ".feed-pane .feed-pane-split-right")
|
||||
split(page, ".feed-pane:last-of-type .feed-pane-split-down")
|
||||
three = page.evaluate(LAYOUT)
|
||||
c.ok("three panels after two splits", three["count"] == 3, str(three["count"]))
|
||||
c.ok("paneCount agrees with the DOM", three["reported"] == three["count"])
|
||||
c.ok("every panel shows a different video",
|
||||
len(set(three["videos"])) == len(three["videos"]), str(three["videos"]))
|
||||
c.ok("panels fit inside the slide", three["within_slide"])
|
||||
c.ok("no control escapes its panel", not three["escaping"], str(three["escaping"]))
|
||||
|
||||
print("\npreload")
|
||||
pre = page.evaluate(PRELOAD)
|
||||
c.ok("the next step exists", pre["next"] is not None)
|
||||
c.ok("every panel of the current step is loaded", pre["current"] and all(pre["current"]),
|
||||
str(pre["current"]))
|
||||
# The point of the exercise: nobody should swipe into an empty panel.
|
||||
c.ok("every panel has its next video preloaded",
|
||||
pre["next"] is not None and all(pre["next"]) and len(pre["next"]) == pre["per"],
|
||||
str(pre["next"]))
|
||||
|
||||
print("\none swipe advances every panel")
|
||||
before = page.evaluate(LAYOUT)["videos"]
|
||||
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
page.wait_for_timeout(3500)
|
||||
after = page.evaluate(LAYOUT)["videos"]
|
||||
c.ok("all panels moved on", all(v not in before for v in after if v),
|
||||
f"{before} -> {after}")
|
||||
c.ok("still preloaded after the swipe",
|
||||
all(page.evaluate(PRELOAD)["next"] or [False]))
|
||||
|
||||
print("\nper-panel audio")
|
||||
page.click(".feed-slide.is-active .feed-pane:first-of-type .feed-pane-mute")
|
||||
page.wait_for_timeout(1200)
|
||||
page.evaluate("() => App.feed.renderSlides()") # re-activate the step
|
||||
page.wait_for_timeout(1500)
|
||||
muted = page.evaluate("""() => Array.from(
|
||||
document.querySelectorAll('.feed-slide.is-active .feed-pane .feed-video')
|
||||
).map(v => v.muted)""")
|
||||
c.ok("only the unmuted panel has sound", muted and muted[0] is False
|
||||
and all(muted[1:]), str(muted))
|
||||
|
||||
print("\nclose a panel")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pane-close")
|
||||
page.wait_for_timeout(3500)
|
||||
closed = page.evaluate(LAYOUT)
|
||||
c.ok("closing collapses the split", closed["count"] == 2, str(closed["count"]))
|
||||
c.ok("survivors still fit the slide", closed["within_slide"])
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
175
tests/smoke_thumbnails.py
Normal file
175
tests/smoke_thumbnails.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thumbnail smoke tests: junk URLs, and images that fail once.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_thumbnails.py
|
||||
|
||||
The listing and the image hosts are both served by this script rather than by a
|
||||
provider, because what's under test is what the client does with awkward data:
|
||||
|
||||
* a `thumb` that isn't a URL at all -- sxyprn's "latest" listing sends items
|
||||
whose thumb is the bare string "https:". Resolved against the page that is
|
||||
*our own* address, so the card used to race our own HTML as if it were a
|
||||
picture, pin our origin to the proxy for the rest of the session, and ask
|
||||
/api/image to fetch "https:" (a 400, every time).
|
||||
|
||||
* a thumbnail whose first request fails. One blip used to mean an empty box
|
||||
for as long as the card stayed mounted.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
CDN = "https://cdn.example-thumbs.test"
|
||||
|
||||
# 1x1 transparent PNG.
|
||||
PIXEL = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
STATE = """() => Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
|
||||
const img = card.querySelector('img');
|
||||
return {
|
||||
id: card.dataset.videoId,
|
||||
src: img ? img.getAttribute('src') || '' : '',
|
||||
loaded: img ? img.naturalWidth > 0 : false,
|
||||
};
|
||||
})"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def listing():
|
||||
"""Twelve ordinary items, one with the junk thumb, one that fails once."""
|
||||
items = []
|
||||
for i in range(12):
|
||||
items.append({
|
||||
"id": f"test:{i}",
|
||||
"title": f"Video {i}",
|
||||
"url": f"{CDN}/watch/{i}",
|
||||
"channel": "test",
|
||||
"duration": 60 + i,
|
||||
"thumb": f"{CDN}/thumb/{i}.png",
|
||||
"tags": [],
|
||||
})
|
||||
items[3]["thumb"] = "https:" # what sxyprn's "latest" actually sends
|
||||
items[7]["thumb"] = f"{CDN}/flaky.png"
|
||||
return {"items": items, "pageInfo": {"hasNextPage": False}}
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
image_proxy_calls = []
|
||||
flaky_hits = {"direct": 0, "proxy": 0}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
|
||||
def serve_listing(route):
|
||||
route.fulfill(status=200, content_type="application/json",
|
||||
body=json.dumps(listing()))
|
||||
|
||||
def serve_proxy(route):
|
||||
# Only the thumbnails matter here; record what the client asked us
|
||||
# to fetch on its behalf, and hand back the picture.
|
||||
image_proxy_calls.append(route.request.url)
|
||||
if "flaky.png" in route.request.url:
|
||||
flaky_hits["proxy"] += 1
|
||||
# The flaky picture is refused on *both* routes the first time
|
||||
# round, which is what used to leave the card empty for good.
|
||||
if flaky_hits["proxy"] == 1:
|
||||
route.fulfill(status=502, content_type="text/plain", body="nope")
|
||||
return
|
||||
route.fulfill(status=200, content_type="image/png", body=PIXEL)
|
||||
|
||||
def serve_cdn(route):
|
||||
if route.request.url.endswith("/flaky.png"):
|
||||
flaky_hits["direct"] += 1
|
||||
if flaky_hits["direct"] == 1:
|
||||
route.abort("connectionfailed")
|
||||
return
|
||||
route.fulfill(status=200, content_type="image/png", body=PIXEL)
|
||||
|
||||
page.route("**/api/videos", serve_listing)
|
||||
page.route("**/api/image*", serve_proxy)
|
||||
page.route(f"{CDN}/**", serve_cdn)
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=60000)
|
||||
# Long enough for the host race (2.5s of patience) and the retry ladder
|
||||
# (~900ms for its second step) to have run their course.
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
cards = page.evaluate(STATE)
|
||||
by_id = {card["id"]: card for card in cards}
|
||||
|
||||
print("\na thumb that isn't a URL")
|
||||
junk = by_id.get("test:3")
|
||||
c.ok("the card is mounted", junk is not None)
|
||||
if junk:
|
||||
c.ok("it asks for nothing at all", junk["src"] == "",
|
||||
f"src={junk['src']!r}")
|
||||
c.ok("and nothing is sent to the image proxy for it",
|
||||
not [u for u in image_proxy_calls if "https%3A&" in u or u.endswith("url=https%3A")],
|
||||
str([u for u in image_proxy_calls if "https%3A" in u][:2]))
|
||||
|
||||
print("\nthe rest of the page is unaffected by it")
|
||||
others = [card for card in cards if card["id"] != "test:3"]
|
||||
c.ok("every other card shows its picture",
|
||||
all(card["loaded"] for card in others),
|
||||
str([card["id"] for card in others if not card["loaded"]]))
|
||||
# The junk URL used to resolve to our own origin, whose race then failed
|
||||
# and pinned it to the proxy -- for everything, for the whole session.
|
||||
c.ok("our own origin is not pinned to the proxy",
|
||||
page.evaluate("() => App.videos.thumbnailUrl(location.origin + '/x.png')")
|
||||
== page.evaluate("() => location.origin + '/x.png'"))
|
||||
|
||||
print("\na thumbnail refused on both routes, once")
|
||||
flaky = by_id.get("test:7")
|
||||
c.ok("the card is mounted", flaky is not None)
|
||||
c.ok("both routes were refused once",
|
||||
flaky_hits["direct"] >= 1 and flaky_hits["proxy"] >= 1,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
c.ok("and it was asked for again after that",
|
||||
flaky_hits["direct"] + flaky_hits["proxy"] > 2,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
if flaky:
|
||||
c.ok("so the card ends up showing a picture", flaky["loaded"],
|
||||
f"src={flaky['src']!r}")
|
||||
c.ok("and a dead thumbnail stops being asked for",
|
||||
flaky_hits["direct"] + flaky_hits["proxy"] <= 4,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
|
||||
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())
|
||||
159
tests/unit_formats.js
Executable file
159
tests/unit_formats.js
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
/* Format selection, tested without a browser.
|
||||
*
|
||||
* node tests/unit_formats.js
|
||||
*
|
||||
* Picking a rendition is pure: a list of formats in, one URL out. That makes it
|
||||
* the one part of playback that can be checked in a second, with no server, no
|
||||
* Chromium and no network -- which matters, because the height cap a split
|
||||
* reels panel applies is the difference between decoding four 1080p streams and
|
||||
* four 480p ones.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
// Enough of a browser for videos.js to finish loading. It builds a few
|
||||
// IntersectionObservers and reads matchMedia at module scope; nothing below
|
||||
// touches the DOM.
|
||||
const noop = () => {};
|
||||
const element = () => ({
|
||||
style: { setProperty: noop, removeProperty: noop },
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
dataset: {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
appendChild: noop,
|
||||
removeChild: noop,
|
||||
remove: noop,
|
||||
getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
setAttribute: noop,
|
||||
removeAttribute: noop,
|
||||
getAttribute: () => null,
|
||||
cloneNode: element,
|
||||
content: { firstElementChild: { cloneNode: element } },
|
||||
children: [],
|
||||
childElementCount: 0,
|
||||
});
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
URL,
|
||||
Image: function () { return element(); },
|
||||
IntersectionObserver: function () {
|
||||
return { observe: noop, unobserve: noop, disconnect: noop };
|
||||
},
|
||||
requestAnimationFrame: noop,
|
||||
requestIdleCallback: noop,
|
||||
performance: { now: () => 0 },
|
||||
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
};
|
||||
sandbox.addEventListener = noop;
|
||||
sandbox.removeEventListener = noop;
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.document = {
|
||||
getElementById: () => null,
|
||||
createElement: element,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
documentElement: element(),
|
||||
body: element(),
|
||||
head: element(),
|
||||
};
|
||||
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop });
|
||||
sandbox.window.location = { href: 'http://localhost/' };
|
||||
|
||||
const context = vm.createContext(sandbox);
|
||||
const load = (file) => vm.runInContext(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', file), 'utf8'), context, file);
|
||||
|
||||
// videos.js reaches for these siblings when a card is built; none of the
|
||||
// functions under test do.
|
||||
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
|
||||
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
|
||||
load('videos.js');
|
||||
|
||||
const { rankFormats, resolveStreamSource, resolveStreamSources } = sandbox.App.videos;
|
||||
|
||||
let failed = 0;
|
||||
const ok = (label, cond, detail) => {
|
||||
if (!cond) failed++;
|
||||
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ url: 'u240', height: 240, vcodec: 'avc1' },
|
||||
{ url: 'u480', height: 480, vcodec: 'avc1' },
|
||||
{ url: 'u720', height: 720, vcodec: 'avc1' },
|
||||
{ url: 'u1080', height: 1080, vcodec: 'avc1' },
|
||||
];
|
||||
const video = { id: 'v1', url: 'https://example.com/watch', meta: { formats: formats } };
|
||||
const heightOf = (src) => (formats.find((f) => f.url === src.url) || {}).height;
|
||||
|
||||
console.log('\nranking');
|
||||
ok('no ceiling takes the best', rankFormats(formats, null)[0].height === 1080);
|
||||
ok('a ceiling takes the best at or below it', rankFormats(formats, 720)[0].height === 720);
|
||||
ok('an exact ceiling is allowed', rankFormats(formats, 480)[0].height === 480);
|
||||
ok('below every rendition still returns one', rankFormats(formats, 100)[0].height === 240,
|
||||
String(rankFormats(formats, 100)[0].height));
|
||||
ok('everything stays reachable as fallback', rankFormats(formats, 480).length === formats.length);
|
||||
|
||||
console.log('\nthe cap a split panel applies');
|
||||
sandbox.App.storage.getPreferredQuality = () => 'auto';
|
||||
ok('uncapped panel gets the best', heightOf(resolveStreamSource(video)) === 1080);
|
||||
ok('a quarter-screen panel gets a quarter-screen rendition',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
|
||||
ok('the cap survives into the fallback order',
|
||||
heightOf(resolveStreamSources(video, { maxHeight: 480 })[0]) === 480);
|
||||
|
||||
console.log('\nthe cap and the quality preference are both ceilings');
|
||||
sandbox.App.storage.getPreferredQuality = () => '720';
|
||||
ok('preference alone caps at 720', heightOf(resolveStreamSource(video)) === 720);
|
||||
ok('the tighter of the two wins (panel)',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
|
||||
sandbox.App.storage.getPreferredQuality = () => '480';
|
||||
ok('the tighter of the two wins (preference)',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 720 })) === 480);
|
||||
ok('a cap never raises the preference',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 2160 })) === 480);
|
||||
|
||||
console.log('\nranking by what it costs to decode');
|
||||
// Same picture, four ways of arriving at it.
|
||||
const mixed = [
|
||||
{ url: 'hls720', height: 720, vcodec: 'avc1', protocol: 'm3u8_native', fps: 30 },
|
||||
{ url: 'av1720', height: 720, vcodec: 'av01.0.05M.08', protocol: 'https', ext: 'mp4', fps: 30 },
|
||||
{ url: 'avc720', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 30, tbr: 900 },
|
||||
{ url: 'avc720p60', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 60, tbr: 2000 },
|
||||
];
|
||||
const mixedVideo = { id: 'v2', url: 'https://example.com/w2', meta: { formats: mixed } };
|
||||
sandbox.App.storage.getPreferredQuality = () => 'auto';
|
||||
|
||||
const best = (opts) => resolveStreamSource(mixedVideo, opts).url;
|
||||
ok('without the flag, bitrate still wins', best({}) === 'avc720p60', best({}));
|
||||
ok('cheapest avoids HLS demuxing in JS', best({ cheapest: true }) !== 'hls720');
|
||||
ok('cheapest avoids software-decoded AV1', best({ cheapest: true }) !== 'av1720');
|
||||
ok('cheapest avoids 60fps', best({ cheapest: true }) !== 'avc720p60');
|
||||
ok('cheapest picks progressive H.264 at 30fps',
|
||||
best({ cheapest: true }) === 'avc720', best({ cheapest: true }));
|
||||
|
||||
// Cheapness must not override the size ceiling, or a split panel would get a
|
||||
// bigger picture than it can afford just because it is cheap per pixel.
|
||||
const tall = [
|
||||
{ url: 'hls480', height: 480, vcodec: 'avc1', protocol: 'm3u8_native' },
|
||||
{ url: 'mp4_1080', height: 1080, vcodec: 'avc1', protocol: 'https', ext: 'mp4' },
|
||||
];
|
||||
const tallVideo = { id: 'v3', url: 'https://example.com/w3', meta: { formats: tall } };
|
||||
ok('the height ceiling still comes first',
|
||||
resolveStreamSource(tallVideo, { maxHeight: 480, cheapest: true }).url === 'hls480');
|
||||
|
||||
ok('every format stays reachable as fallback',
|
||||
rankFormats(mixed, null, { cheapest: true }).length === mixed.length);
|
||||
|
||||
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user