1/* ============================================================================ 2 * shorts/feed.js v0.25.4 â TikTok/Reels-grade feed entry point. 3 * 4 * Architecture: 5 * 1. Bootstrap из <script id="pb-shorts-bootstrap" type="application/json">. 6 * 2. Mount Embla Carousel vertical на #pbShortsViewport. 7 * 3. Virtualize: render PbShort instance current ± 2; destroy за окном. 8 * 4. Pre-fetch ÑледÑÑÑий batch когда user в â¤3 items Ð¾Ñ ÐºÐ¾Ð½Ñа. 9 * 5. Hint-overlay first-visit (localStorage). 10 * 6. Right-side action stack: 11 * - ð Like (localStorage state + animated heart burst on double-tap) 12 * - ð Dislike 13 * - â Share (Web Share API + share_card.js) 14 * - ð Save (localStorage bookmarks) 15 * - ð ÐолнÑй оÑÑÑÑ â /p/{shortId} 16 * - Marketplace «avatar» â /?mp=... 17 * 7. Double-tap stage = like + animated burst. 18 * 8. Long-press = pause; quick-tap = pause/resume; explicit ⸠button в top-nav. 19 * 9. Keyboard nav: â/â = scroll prev/next; Esc = close; PgUp/PgDn = 5x jump. 20 * 10. Mouse-tracking 3D parallax (desktop only, fine pointer). 21 * 11. Auto-hide top progress 1500ms поÑле settle. 22 * 12. Yandex.Metrika events on каждÑй action. 23 * ========================================================================== */ 24(function () { 25 "use strict"; 26 27 var $ = function (id) { return document.getElementById(id); }; 28 29 function readBootstrap() { 30 var node = document.getElementById("pb-shorts-bootstrap"); 31 if (!node) return { items: [], next_cursor: null, verdict_counts: {} }; 32 try { return JSON.parse(node.textContent || "{}"); } 33 catch (e) { console.warn("[shorts] bootstrap parse failed", e); return { items: [], next_cursor: null }; } 34 } 35 36 /* v0.25.17: trackGoal ÑанÑÑе делал sync fetch POST на каждÑй Embla 37 select+settle event = 2 network calls per swipe blocking swipe-settle paint. 38 Now: Yandex.Metrika reachGoal оÑÑаÑÑÑÑ inline (он Ñам бÑÑеÑизÑеÑ), а fetch 39 deferred ÑеÑез setTimeout(0) â UI responsive, network call happens off 40 critical path. sendBeacon prefer'им еÑли доÑÑÑпен (browser-managed, 41 0 main-thread cost). */ 42 function trackGoal(name, props) { 43 try { 44 if (window.ym && window.METRIKA_ID) { 45 window.ym(window.METRIKA_ID, "reachGoal", name, props || {}); 46 } 47 } catch (_) {} 48 // Defer the server POST off the critical path 49 setTimeout(function () { 50 try { 51 var body = JSON.stringify(Object.assign({ type: name, ts: Date.now() }, props || {})); 52 if (navigator.sendBeacon) { 53 var blob = new Blob([body], { type: "application/json" }); 54 navigator.sendBeacon("/api/shorts/event", blob); 55 } else if (window.fetch) { 56 window.fetch("/api/shorts/event", { 57 method: "POST", 58 headers: { "Content-Type": "application/json" }, 59 body: body, 60 keepalive: true, 61 }).catch(function () {}); 62 } 63 } catch (_) {} 64 }, 0); 65 } 66 67 /* ââ localStorage state âââââââââââââââââââââââââââââââââââââââââââââââ */ 68 function getList(key) { 69 try { 70 var raw = window.localStorage.getItem(key); 71 if (!raw) return []; 72 var arr = JSON.parse(raw); 73 return Array.isArray(arr) ? arr.filter(function (n) { return Number.isInteger(n); }) : []; 74 } catch (_) { return []; } 75 } 76 function setList(key, arr, cap) { 77 try { 78 arr = arr.slice(-(cap || 500)); 79 window.localStorage.setItem(key, JSON.stringify(arr)); 80 } catch (_) {} 81 } 82 function inList(key, id) { return getList(key).indexOf(id) !== -1; } 83 function addToList(key, id, cap) { 84 var arr = getList(key); 85 if (arr.indexOf(id) !== -1) return; 86 arr.push(id);
87 setList(key, arr, cap); 88 } 89 function removeFromList(key, id) { 90 var arr = getList(key); 91 var idx = arr.indexOf(id); 92 if (idx === -1) return; 93 arr.splice(idx, 1); 94 setList(key, arr); 95 } 96 97 var SEEN_KEY = "pb_seen_short_ids"; 98 var LIKED_KEY = "pb_liked_short_ids"; 99 var SAVED_KEY = "pb_saved_short_ids"; 100 var DISLIKED_KEY = "pb_disliked_short_ids"; // v0.25.11 101 102 /* v0.25.11: server-sync Ð´Ð»Ñ Ð»Ð¾Ð³Ð¸Ð½ÐµÐ½Ð½ÑÑ ÑзеÑов. Anon â 401 silent fail (UI 103 оÑÑаÑÑÑÑ optimistic из localStorage). Logged â POST идÑÑ, response Ñ 104 likes_count обновлÑÐµÑ counter. ÐÑполÑзÑем keepalive Ð´Ð»Ñ Ð½Ð°Ð´ÑжноÑÑи. */ 105 function postLikeRemote(aid, liked) { 106 try { 107 return window.fetch("/api/shorts/" + aid + "/like", { 108 method: "POST", 109 headers: { "Content-Type": "application/json" }, 110 body: JSON.stringify({ liked: !!liked }), 111 credentials: "same-origin", 112 keepalive: true, 113 }).then(function (r) { 114 if (!r.ok) return null; 115 return r.json().catch(function () { return null; }); 116 }).catch(function () { return null; }); 117 } catch (_) { return Promise.resolve(null); } 118 } 119 function postSaveRemote(aid, saved) { 120 try { 121 return window.fetch("/api/shorts/" + aid + "/save", { 122 method: "POST", 123 headers: { "Content-Type": "application/json" }, 124 body: JSON.stringify({ saved: !!saved }), 125 credentials: "same-origin", 126 keepalive: true, 127 }).catch(function () {}); 128 } catch (_) {} 129 } 130 131 function isOnboarded() { 132 try { return window.localStorage.getItem("pb_shorts_onboarded") === "1"; } 133 catch (_) { return false; } 134 } 135 function markOnboarded() { 136 try { window.localStorage.setItem("pb_shorts_onboarded", "1"); } 137 catch (_) {} 138 } 139 140 /* ââ Low-end device detection âââââââââââââââââââââââââââââââââââââââââ */ 141 function isLowEnd() { 142 try { 143 var mem = navigator.deviceMemory; 144 var cores = navigator.hardwareConcurrency; 145 var conn = navigator.connection && navigator.connection.effectiveType; 146 return (typeof mem === "number" && mem <= 2) || 147 (typeof cores === "number" && cores < 4) || 148 (typeof conn === "string" && /^(slow-2g|2g|3g)$/.test(conn)); 149 } catch (_) { return false; } 150 } 151 var PB_LOW_END = isLowEnd(); 152 153 /* ââ Coarse pointer (touch) detection âââââââââââââââââââââââââââââââââ */ 154 function isCoarsePointer() { 155 try { 156 return window.matchMedia && window.matchMedia("(pointer: coarse)").matches; 157 } catch (_) { return false; } 158 } 159 function prefersReducedMotion() { 160 try { 161 return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; 162 } catch (_) { return false; } 163 } 164 var PB_COARSE = isCoarsePointer(); 165 var PB_REDUCED = prefersReducedMotion(); 166 167 /* ââ State ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 168 var state = { 169 items: [], 170 cursor: null, 171 loading: false, 172 exhausted: false, 173 currentIdx: 0, 174 shorts: {}, 175 embla: null, 176 muted: true, 177 userPaused: false, 178 sessionShortsViewed: 0, 179 sessionStarted: Date.now(), 180 viewedTimers: {}, 181 autoHideTimer: null, 182 }; 183 184 /* ââ DOM ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 185 var trackEl = null, viewportEl = null, progressEl = null; 186 var hintEl = null, muteBtn = null, pauseBtn = null, topnavEl = null; 187 188 /* ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 189 function mpLabel(mp) { 190 var labels = { 191 wildberries: "Wildberries", wb: "Wildberries", 192 ozon: "Ozon", 193 yandex_market: "Я.ÐаÑкеÑ", ym: "Я.ÐаÑкеÑ", 194 megamarket: "ÐегамаÑкеÑ", mm: "ÐегамаÑкеÑ", sbermegamarket: "ÐегамаÑкеÑ", 195 }; 196 var key = (mp || "").toLowerCase(); 197 return labels[key] || (key ? key.charAt(0).toUpperCase() + key.slice(1) : "ÐаÑкеÑплейÑ"); 198 } 199 function mpAvatar(mp) { 200 // v0.25.69: полнÑе Ð½Ð°Ð·Ð²Ð°Ð½Ð¸Ñ (как везде); CSS Ð´ÐµÐ»Ð°ÐµÑ pill compact. 201 var k = (mp || "").toLowerCase(); 202 if (k === "wildberries" || k === "wb") return "Wildberries"; 203 if (k === "ozon") return "Ozon"; 204 if (k === "yandex_market" || k === "ym") return "Я.ÐаÑкеÑ"; 205 if (k === "megamarket" || k === "mm" || k === "sbermegamarket") return "ÐегамаÑкеÑ"; 206 return "â"; 207 } 208 function verdictLine(verdict, suspPct) { 209 var sp = (typeof suspPct === "number") ? Math.round(suspPct) : 0; 210 if (verdict === "avoid") return "ð ÐÑÑÑе не бÑаÑÑ Â· " + sp + "% накÑÑÑка"; 211 if (verdict === "caution") return "â С оÑÑоÑожноÑÑÑÑ Â· " + sp + "% накÑÑÑка"; 212 if (verdict === "trusted") return "â Ðожно бÑаÑÑ Â· " + sp + "% подозÑ."; 213 return "· Ðнализ"; 214 } 215 /* v0.25.11: BIG verdict badge â болÑÑие % в кÑÑглом badge + verdict text ÑÑдом. 216 ЮзеÑ: «пÑоÑенÑÑ ÑделаÑÑ Ñвно виднее, а Ñо инаÑе не видно Ð¸Ñ Â». */ 217 function renderVerdictBadge(verdict, suspPct) { 218 var sp = (typeof suspPct === "number") ? Math.round(suspPct) : 0; 219 var icon = verdict === "avoid" ? "ð" : verdict === "caution" ? "â " : verdict === "trusted" ? "â" : "·"; 220 var text = verdict === "avoid" ? "ÐÑÑÑе не бÑаÑÑ" 221 : verdict === "caution" ? "С оÑÑоÑожноÑÑÑÑ" 222 : verdict === "trusted" ? "Ðожно бÑаÑÑ" 223 : "Ðнализ"; 224 return '<div class="pb-short-caption-verdict-wrapper">' + 225 '<div class="pb-short-caption-verdict-badge" aria-label="ÐеÑÐ´Ð¸ÐºÑ ' + sp + '%">' + 226 '<span class="pb-short-caption-pct">' + sp + '<span class="pb-short-caption-pct-unit">%</span></span>' + 227 '</div>' + 228 '<div class="pb-short-caption-verdict-text">' + 229 '<span class="pb-short-caption-verdict-icon">' + icon + '</span>' + 230 escapeHtml(text) + 231 '</div>' + 232 '</div>'; 233 } 234 /* v0.31: Ñена из result_json.product â Ð´Ð»Ñ ÑакÑ-ÑÑÑоки в caption */ 235 function productPrice(item) { 236 try { 237 var rj = item.result_json || {}; 238 var prod = rj.product || {}; 239 var p = prod.price_rub || prod.price || rj.price_rub || 0; 240 p = parseFloat(p); 241 return (isFinite(p) && p > 0) ? p : 0; 242 } catch (_) { return 0; } 243 } 244 function fmtPrice(p) {
245 try { return Math.round(p).toLocaleString("ru-RU") + " â½"; } 246 catch (_) { return Math.round(p) + " â½"; } 247 } 248 function fmtCount(n) { 249 if (typeof n !== "number" || n <= 0) return "·"; 250 if (n < 1000) return String(n); 251 if (n < 10000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "K"; 252 if (n < 1000000) return Math.round(n / 1000) + "K"; 253 return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M"; 254 } 255 256 /* ââ Article DOM builder (TikTok-style layout) ââââââââââââââââââââââââ */ 257 function buildArticleEl(item, isFirst) { 258 var article = document.createElement("article"); 259 article.className = "pb-short"; 260 article.setAttribute("data-aid", String(item.id)); 261 article.setAttribute("data-short-id", item.short_id); 262 article.setAttribute("data-verdict", item.verdict || "unknown"); 263 article.setAttribute("data-marketplace", item.marketplace || ""); 264 if (isFirst) article.setAttribute("data-initial", "1"); 265 // v0.25.25: Instagram-style blurred backdrop â set CSS variable Ñ image url. 266 // CSS .pb-short-stage::before ÑендеÑÐ¸Ñ ÑÑÐ¾Ñ image как backdrop (blur + dim). 267 if (item.image_url) { 268 article.style.setProperty("--pb-short-bg", "url('" + item.image_url.replace(/'/g, "\\'") + "')"); 269 } 270 271 // 1. Stage (full-bleed Investigation mount-point) 272 var stage = document.createElement("div"); 273 stage.className = "pb-short-stage"; 274 stage.setAttribute("aria-label", "РаÑÑледование ÑоваÑа"); 275 // v0.25.23: pre-show product image â Ð¼Ð³Ð½Ð¾Ð²ÐµÐ½Ð½Ð°Ñ Ð²Ð¸Ð´Ð¸Ð¼Ð¾ÑÑÑ Ð¿Ñи Ñвайпе. 276 // Investigation overlay повеÑÑ ÐºÐ¾Ð³Ð´Ð° mountиÑÑÑ; preview fade-out. 277 if (item.image_url) { 278 var preview = document.createElement("img"); 279 preview.className = "pb-short-preview-img"; 280 preview.src = item.image_url; 281 preview.alt = ""; 282 preview.loading = "lazy"; 283 preview.decoding = "async"; 284 preview.referrerPolicy = "no-referrer"; 285 preview.onerror = function () { this.style.display = "none"; }; 286 stage.appendChild(preview); 287 } 288 article.appendChild(stage); 289 290 // v0.31: веÑÑ Ð½ÑÑ Ð²ÐµÑдикÑ-плаÑка Ñловами + маÑкеÑÐ¿Ð»ÐµÐ¹Ñ (Ñлой 1) 291 var topbar = document.createElement("div"); 292 topbar.className = "pb-short-topbar"; 293 topbar.setAttribute("aria-hidden", "true"); 294 var vIcon = item.verdict === "avoid" ? "ð" : item.verdict === "caution" ? "â " : item.verdict === "trusted" ? "â" : "·"; 295 var vText = item.verdict === "avoid" ? "ÐÑÑÑе не бÑаÑÑ" 296 : item.verdict === "caution" ? "С оÑÑоÑожноÑÑÑÑ" 297 : item.verdict === "trusted" ? "Ðожно бÑаÑÑ" : "Ðнализ"; 298 topbar.innerHTML = 299 '<span class="pb-short-topbar-verdict">' + vIcon + " " + escapeHtml(vText) + '</span>' + 300 '<span class="pb-short-topbar-mp">' + escapeHtml(mpLabel(item.marketplace)) + '</span>'; 301 article.appendChild(topbar); 302 303 // 2. Bottom-left caption (mp + name + verdict) 304 var caption = document.createElement("div"); 305 caption.className = "pb-short-caption"; 306 // v0.31: name + Ñена/оÑзÑÐ²Ñ + CTA «ÐолнÑй ÑазбоÑ» (verdict-badge ÑкÑÑÑ CSS'ом v0.25.18) 307 var _price = productPrice(item); 308 caption.innerHTML = 309 '<div class="pb-short-caption-mp">' + escapeHtml(mpLabel(item.marketplace).toLowerCase()) + '</div>' + 310 '<div class="pb-short-caption-name">' + escapeHtml(item.name || "ТоваÑ") + '</div>' + 311 '<div class="pb-short-caption-facts">' + 312 (_price ? '<span class="pb-short-caption-price">' + fmtPrice(_price) + '</span>' : '') + 313 (item.reviews_count ? '<span class="pb-short-caption-reviews">' + fmtCount(item.reviews_count) + ' оÑзÑвов пÑовеÑено</span>' : '') + 314 '</div>' + 315 renderVerdictBadge(item.verdict, item.suspicion_pct) + 316 '<a class="pb-short-caption-cta" data-act="report" href="/p/' + escapeHtml(item.short_id) + '">ÐолнÑй ÑÐ°Ð·Ð±Ð¾Ñ â</a>'; 317 article.appendChild(caption); 318 319 // 3. Right-side action stack (TikTok pattern) 320 var actions = document.createElement("div"); 321 actions.className = "pb-short-actions-stack"; 322 actions.setAttribute("aria-label", "ÐейÑÑвиÑ"); 323 // v0.25.39: data-embla-no-drag â Embla skipÐ¸Ñ touch interception на ÑÑом 324 // ÑлеменÑе. РанÑÑе на slow tap или micro-movement Embla detected drag â 325 // preventDefault на touchmove â button click blocked. ЮзеÑ: «иногда 326 // ÑÑпо лайки не ÑÑавÑÑÑÑ». 327 actions.setAttribute("data-embla-no-drag", ""); 328 329 // Like 330 var likeBtn = document.createElement("button"); 331 likeBtn.type = "button"; 332 likeBtn.className = "pb-short-act-btn"; 333 likeBtn.setAttribute("data-act", "like"); 334 likeBtn.setAttribute("aria-label", "ÐÑавиÑÑÑ"); 335 likeBtn.innerHTML = 336 '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' + 337 '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>' + 338 '</svg></span>' + 339 '<span class="pb-short-act-label" data-counter="likes">' + fmtCount(item.likes_count || 0) + '</span>'; 340 actions.appendChild(likeBtn); 341 342 // Dislike 343 var dislikeBtn = document.createElement("button"); 344 dislikeBtn.type = "button"; 345 dislikeBtn.className = "pb-short-act-btn"; 346 dislikeBtn.setAttribute("data-act", "dislike"); 347 dislikeBtn.setAttribute("aria-label", "Ðе нÑавиÑÑÑ"); 348 dislikeBtn.innerHTML = 349 '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' + 350 '<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>' + 351 '</svg></span>' + 352 '<span class="pb-short-act-label">·</span>'; 353 actions.appendChild(dislikeBtn); 354 355 // Share 356 var shareBtn = document.createElement("button"); 357 shareBtn.type = "button"; 358 shareBtn.className = "pb-short-act-btn"; 359 shareBtn.setAttribute("data-act", "share"); 360 shareBtn.setAttribute("aria-label", "ÐоделиÑÑÑÑ"); 361 shareBtn.innerHTML = 362 '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' + 363 '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>' + 364 '<polyline points="16 6 12 2 8 6"/>' + 365 '<line x1="12" y1="2" x2="12" y2="15"/></svg></span>' + 366 '<span class="pb-short-act-label">ÐоделиÑÑÑÑ</span>'; 367 actions.appendChild(shareBtn); 368 369 // Save 370 var saveBtn = document.createElement("button"); 371 saveBtn.type = "button"; 372 saveBtn.className = "pb-short-act-btn"; 373 saveBtn.setAttribute("data-act", "save"); 374 saveBtn.setAttribute("aria-label", "Ð¡Ð¾Ñ ÑаниÑÑ"); 375 saveBtn.innerHTML = 376 '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' + 377 '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>' + 378 '</svg></span>' + 379 '<span class="pb-short-act-label">Ð¡Ð¾Ñ ÑаниÑÑ</span>'; 380 actions.appendChild(saveBtn); 381 382 // Report 383 var reportBtn = document.createElement("a"); 384 reportBtn.className = "pb-short-act-btn"; 385 reportBtn.href = "/p/" + item.short_id; 386 reportBtn.setAttribute("data-act", "report"); 387 reportBtn.setAttribute("aria-label", "ÐолнÑй оÑÑÑÑ"); 388 reportBtn.innerHTML = 389 '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' + 390 '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>' + 391 '<polyline points="14 2 14 8 20 8"/></svg></span>' + 392 '<span class="pb-short-act-label">РазбоÑ</span>'; 393 actions.appendChild(reportBtn); 394 395 // v0.25.23: «Рмагазин» â quick link на marketplace product page 396 if (item.product_url) { 397 var shopBtn = document.createElement("a"); 398 shopBtn.className = "pb-short-act-btn"; 399 shopBtn.href = item.product_url; 400 shopBtn.target = "_blank"; 401 shopBtn.rel = "noopener noreferrer nofollow"; 402 shopBtn.setAttribute("data-act", "shop"); 403 shopBtn.setAttribute("aria-label", "ÐÑкÑÑÑÑ Ð² магазине"); 404 shopBtn.innerHTML = 405 '<span class="pb-short-act-icon"><svg viewBox="0 0 24 24" aria-hidden="true">' + 406 '<path d="M6 2l-4 6h20L18 2H6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>' + 407 '<path d="M2 8v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>' + 408 '<path d="M8 12a4 4 0 0 0 8 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>' + 409 '<span class="pb-short-act-label">Рмагазин</span>'; 410 shopBtn.addEventListener("click", function () { 411 trackGoal("short_shop_clicked", { 412 aid: item.id, short_id: item.short_id, marketplace: item.marketplace 413 }); 414 }); 415 actions.appendChild(shopBtn); 416 } 417 418 // v0.25.11: единÑй wire-up handlers (иÑполÑзÑеÑÑÑ Ð¸ Ð´Ð»Ñ SSR cards) 419 attachActionHandlers(article, item); 420 421 // Marketplace avatar 422 var mpAv = document.createElement("a"); 423 mpAv.className = "pb-short-act-mp"; 424 mpAv.href = "/?mp=" + encodeURIComponent(item.marketplace || "");
425 mpAv.setAttribute("aria-label", mpLabel(item.marketplace)); 426 mpAv.textContent = mpAvatar(item.marketplace); 427 actions.appendChild(mpAv); 428 429 article.appendChild(actions); 430 431 // 4. Like burst (animated heart on double-tap) 432 var burst = document.createElement("div"); 433 burst.className = "pb-short-like-burst"; 434 burst.setAttribute("aria-hidden", "true"); 435 burst.innerHTML = 436 '<svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>'; 437 article.appendChild(burst); 438 439 // 5. Pause indicator (CSS-only floating overlay, shown when .is-user-paused) 440 var pauseInd = document.createElement("div"); 441 pauseInd.className = "pb-short-pause-indicator"; 442 pauseInd.setAttribute("aria-hidden", "true"); 443 pauseInd.innerHTML = 444 '<svg viewBox="0 0 24 24" aria-hidden="true">' + 445 '<rect x="6" y="5" width="4" height="14" rx="1.2"/>' + 446 '<rect x="14" y="5" width="4" height="14" rx="1.2"/>' + 447 '</svg>'; 448 article.appendChild(pauseInd); 449 450 return article; 451 } 452 453 function escapeHtml(s) { 454 return String(s == null ? "" : s) 455 .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") 456 .replace(/"/g, """).replace(/'/g, "'"); 457 } 458 459 /* ââ v0.25.11: единÑй wire-up Ð´Ð»Ñ action-rail (SSR + dynamic articles) â 460 РанÑÑе handlers навеÑивалиÑÑ inline в buildArticleEl, поÑÑÐ¾Ð¼Ñ SSR-cards 461 (initial 2 в shorts.html) оÑÑавалиÑÑ "мÑÑÑвÑми" â кнопки visible, но не 462 ÑабоÑали. ЮзеÑ: «в ленÑе кнопки не ÑабоÑаÑÑ». Now: extract в helper + 463 вÑзÑваÑÑ Ð¸ из buildArticleEl, и из wireSsrArticles в init(). */ 464 function attachActionHandlers(article, item) { 465 if (!article) return; 466 if (article.getAttribute("data-acts-wired") === "1") return; // idempotent 467 article.setAttribute("data-acts-wired", "1"); 468 469 var likeBtn = article.querySelector('[data-act="like"]'); 470 var dislikeBtn = article.querySelector('[data-act="dislike"]'); 471 var shareBtn = article.querySelector('[data-act="share"]'); 472 var saveBtn = article.querySelector('[data-act="save"]'); 473 var reportBtn = article.querySelector('[data-act="report"]'); 474 475 if (likeBtn) { 476 if (inList(LIKED_KEY, item.id)) likeBtn.classList.add("is-liked"); 477 likeBtn.addEventListener("click", function (e) { 478 e.stopPropagation(); 479 toggleLike(article, item, likeBtn); 480 }); 481 } 482 if (dislikeBtn) { 483 if (inList(DISLIKED_KEY, item.id)) dislikeBtn.classList.add("is-disliked"); 484 dislikeBtn.addEventListener("click", function (e) { 485 e.stopPropagation(); 486 toggleDislike(article, item, dislikeBtn); 487 }); 488 } 489 if (shareBtn) { 490 shareBtn.addEventListener("click", function (e) { 491 e.stopPropagation(); 492 openShare(item); 493 }); 494 } 495 if (saveBtn) { 496 if (inList(SAVED_KEY, item.id)) saveBtn.classList.add("is-saved"); 497 saveBtn.addEventListener("click", function (e) { 498 e.stopPropagation(); 499 toggleSave(item, saveBtn); 500 }); 501 } 502 if (reportBtn) { 503 // v0.25.11: на SSR ÑÑо <a href="/p/...">. Click ÑÐ¾Ð³Ð³Ð»Ð¸Ñ Ð½Ð° полнÑй 504 // оÑÑÑÑ, Ð¼Ñ ÑолÑко ÑÑекаем ÑобÑÑие. 505 reportBtn.addEventListener("click", function () { 506 trackGoal("short_full_report_clicked", { 507 aid: item.id, short_id: item.short_id, verdict: item.verdict 508 }); 509 }); 510 } 511 } 512 513 /* ââ Likes / saves / dislikes logic âââââââââââââââââââââââââââââââââââ */ 514 function toggleLike(article, item, btn) { 515 var isLiked = btn.classList.toggle("is-liked"); 516 if (isLiked) { 517 addToList(LIKED_KEY, item.id); 518 // v0.25.11: еÑли Ñанее бÑл dislike â ÑбÑаÑÑваем (mutual exclusive) 519 if (inList(DISLIKED_KEY, item.id)) { 520 removeFromList(DISLIKED_KEY, item.id); 521 var dis = article && article.querySelector('[data-act="dislike"]'); 522 if (dis) dis.classList.remove("is-disliked"); 523 } 524 trackGoal("short_liked", { aid: item.id, short_id: item.short_id }); 525 updateCounter(btn, "likes", 1); 526 fireBurst(article); 527 postLikeRemote(item.id, true).then(function (r) { 528 // ÐÑли backend веÑнÑл ÑеалÑнÑй counter â sync UI 529 if (r && typeof r.likes_count === "number") { 530 var label = btn.querySelector('[data-counter="likes"]'); 531 if (label) { 532 label.setAttribute("data-num", String(r.likes_count)); 533 label.textContent = fmtCount(r.likes_count); 534 } 535 } 536 }); 537 } else { 538 removeFromList(LIKED_KEY, item.id); 539 trackGoal("short_unliked", { aid: item.id }); 540 updateCounter(btn, "likes", -1); 541 postLikeRemote(item.id, false); 542 } 543 } 544 function toggleSave(item, btn) { 545 var saved = btn.classList.toggle("is-saved"); 546 if (saved) { 547 addToList(SAVED_KEY, item.id); 548 trackGoal("short_saved", { aid: item.id, short_id: item.short_id }); 549 } else { 550 removeFromList(SAVED_KEY, item.id); 551 trackGoal("short_unsaved", { aid: item.id }); 552 } 553 postSaveRemote(item.id, saved); 554 } 555 /* v0.25.11: dislike-кнопка ÑанÑÑе Ñогглила .is-liked (copy-paste bug) и 556 не ÑÐ¾Ñ ÑанÑла ÑоÑÑоÑние. Now: persists в localStorage DISLIKED_KEY, 557 оÑделÑнÑй клаÑÑ .is-disliked, пиÑÐµÑ signal на /api/shorts/event. */ 558 function toggleDislike(article, item, btn) { 559 var isDisliked = btn.classList.toggle("is-disliked"); 560 if (isDisliked) { 561 addToList(DISLIKED_KEY, item.id); 562 // mutual exclusive Ñ like 563 if (inList(LIKED_KEY, item.id)) { 564 removeFromList(LIKED_KEY, item.id); 565 var lk = article && article.querySelector('[data-act="like"]'); 566 if (lk) { 567 lk.classList.remove("is-liked"); 568 updateCounter(lk, "likes", -1); 569 postLikeRemote(item.id, false); 570 } 571 } 572 trackGoal("short_disliked", { aid: item.id, short_id: item.short_id }); 573 } else { 574 removeFromList(DISLIKED_KEY, item.id); 575 trackGoal("short_undisliked", { aid: item.id }); 576 } 577 } 578 function updateCounter(btn, kind, delta) { 579 try { 580 var label = btn.querySelector('[data-counter="' + kind + '"]'); 581 if (!label) return; 582 var cur = parseInt(label.getAttribute("data-num") || "0", 10) || 0; 583 var next = Math.max(0, cur + delta); 584 label.setAttribute("data-num", String(next)); 585 label.textContent = fmtCount(next); 586 } catch (_) {} 587 } 588 function fireBurst(article) { 589 if (!article) return; 590 var burst = article.querySelector(".pb-short-like-burst"); 591 if (!burst) return; 592 var rect = article.getBoundingClientRect(); 593 burst.style.left = (rect.width / 2) + "px"; 594 burst.style.top = (rect.height / 2) + "px"; 595 burst.classList.remove("is-firing"); 596 void burst.offsetWidth; 597 burst.classList.add("is-firing"); 598 setTimeout(function () { burst.classList.remove("is-firing"); }, 750); 599 } 600 601 /* ââ Share logic ââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 602 function openShare(item) { 603 trackGoal("short_share_open", { aid: item.id, short_id: item.short_id }); 604 if (window.PbShareCard && typeof window.PbShareCard.openShareCardModal === "function") { 605 try { 606 var data = { 607 url: "https://probiwaem.ru/shorts/" + item.short_id, 608 product_name: item.name, 609 marketplace: item.marketplace, 610 verdict: item.verdict, 611 suspicion_pct: item.suspicion_pct, 612 reviews_count: item.reviews_count, 613 image_url: item.image_url || "", 614 }; 615 window.PbShareCard.openShareCardModal(data); 616 return; 617 } catch (e) { console.warn("[shorts] PbShareCard failed", e); } 618 } 619 if (navigator.share) { 620 navigator.share({ 621 title: "РаÑÑледование probiwaem.ru", 622 text: item.name + " â " + verdictLine(item.verdict, item.suspicion_pct), 623 url: "https://probiwaem.ru/shorts/" + item.short_id, 624 }).catch(function () {}); 625 } else { 626 try { 627 navigator.clipboard.writeText("https://probiwaem.ru/shorts/" + item.short_id); 628 } catch (_) {} 629 } 630 } 631 632 /* ââ Mount/unmount Short instance âââââââââââââââââââââââââââââââââââââ */ 633 function mountShort(article) { 634 var aid = parseInt(article.getAttribute("data-aid"), 10); 635 if (!aid || state.shorts[aid]) return state.shorts[aid] || null; 636 var item = state.items.filter(function (i) { return i.id === aid; })[0]; 637 if (!item) return null; 638 if (typeof window.PbShort !== "function") return null; 639 var short = new window.PbShort(article, item, { 640 mute: state.muted, 641 progressEl: progressEl, 642 onComplete: function () { 643 trackGoal("short_completed", { 644 aid: aid, short_id: item.short_id, verdict: item.verdict, 645 }); 646 }, 647 }); 648 state.shorts[aid] = short; 649 return short; 650 } 651 function destroyShort(aid) { 652 var short = state.shorts[aid]; 653 if (!short) return; 654 try { short.destroy(); } catch (_) {} 655 delete state.shorts[aid]; 656 } 657 658 /* ââ Embla setup + virtualization ââââââââââââââââââââââââââââââââââââââ */ 659 function initEmbla() { 660 var EmblaCtor = window.EmblaCarousel || window.EmblaCarouselUMD; 661 if (typeof EmblaCtor !== "function") return null; 662 var embla = EmblaCtor(viewportEl, { 663 axis: "y", 664 loop: false, 665 dragFree: false, 666 containScroll: "trimSnaps", 667 skipSnaps: false, 668 duration: PB_LOW_END ? 18 : 25, 669 // v0.25.41: watchDrag function â Embla 8.x ÐРподдеÑÐ¶Ð¸Ð²Ð°ÐµÑ 670 // data-embla-no-drag аÑÑибÑÑ (мой v0.25.39 не ÑабоÑал!). 671 // ÐÑа ÑÑнкÑÐ¸Ñ Ð²Ð¾Ð·Ð²ÑаÑÐ°ÐµÑ false â Embla skipÐ¸Ñ drag handling 672 // на action-rail buttons + topnav. ЮзеÑ: «лайк не ÑÑабаÑÑваеÑ». 673 watchDrag: function (_embla, evt) { 674 var t = evt.target; 675 if (!t || !t.closest) return true; 676 if (t.closest("[data-act], .pb-short-actions-stack, .pb-short-act-btn, .pb-short-act-mp, .pb-shorts-topnav, [data-embla-no-drag]")) { 677 return false; // не ÑÑиÑаÑÑ Ð·Ð° drag â click fires ноÑмалÑно 678 } 679 return true; // обÑÑнÑй stage â Embla обÑабаÑÑÐ²Ð°ÐµÑ Ñвайп 680 }, 681 }); 682 embla.on("select", onEmblaSelect); 683 embla.on("settle", onEmblaSettle); 684 return embla; 685 } 686 function onEmblaSelect() { 687 if (!state.embla) return; 688 var idx = state.embla.selectedScrollSnap();
689 var prev = state.currentIdx; 690 if (idx === prev) return; 691 state.currentIdx = idx; 692 // On scroll: clear user-paused state on previous short, reset top-nav vis 693 clearUserPausedAll(); 694 showTopnav(); 695 if (idx > prev) trackGoal("short_swipe_next", { from_idx: prev, to_idx: idx }); 696 else trackGoal("short_swipe_prev", { from_idx: prev, to_idx: idx }); 697 } 698 function onEmblaSettle() { 699 if (!state.embla) return; 700 var idx = state.embla.selectedScrollSnap(); 701 // v0.25.15: memoize querySelectorAll â ÑанÑÑе virtualize() + playCurrentShort() 702 // вÑзÑвали querySelectorAll(".pb-short") Ð´Ð²Ð°Ð¶Ð´Ñ Ð½Ð° каждÑй settle = O(2n). 703 // Now: 1 query â пеÑедаÑм NodeList в обе ÑÑнкÑии. 704 var articles = trackEl.querySelectorAll(".pb-short"); 705 // v0.25.15: defer mount/destroy ÑеÑез setTimeout(0) â Investigation._build() 706 // блокиÑÑÐµÑ main thread на 80-150ms. Defer ÑÑÐ¾Ð±Ñ Embla settle-transition 707 // ÑÑпел завеÑÑиÑÑÑÑ Ð±ÐµÐ· jank, поÑом mount нового short. 708 setTimeout(function () { virtualize(idx, articles); }, 0); 709 playCurrentShort(idx, articles); 710 scheduleTopnavAutoHide(); 711 if (state.items.length - idx <= 3 && !state.exhausted) loadNextBatch(); 712 if (idx > 0 && !isOnboarded()) { markOnboarded(); hideHint(); } 713 } 714 function virtualize(activeIdx, articles) { 715 articles = articles || trackEl.querySelectorAll(".pb-short"); 716 // v0.25.15: window ±2 â ±1 (3 mounted instead of 5). Cinematic CSS 717 // композиÑ-layers + per-instance Investigation = expensive Ñ 5 active. 718 // 3 = current + prev + next (snappy swipes без preload-storm). 719 for (var i = 0; i < articles.length; i++) { 720 var article = articles[i]; 721 var aid = parseInt(article.getAttribute("data-aid"), 10); 722 if (Math.abs(i - activeIdx) <= 1) { 723 if (!state.shorts[aid]) mountShort(article); 724 } else { 725 if (state.shorts[aid]) { 726 // v0.25.15: defer destroyShort ÑеÑез setTimeout â ÑÑаÑÑй Investigation 727 // close() heavy (kill GSAP timelines, remove listeners). Ðе блокиÑÑем main. 728 (function (a) { setTimeout(function () { destroyShort(a); }, 0); })(aid); 729 } 730 } 731 } 732 } 733 function playCurrentShort(idx, articles) { 734 articles = articles || trackEl.querySelectorAll(".pb-short"); 735 for (var i = 0; i < articles.length; i++) { 736 var article = articles[i]; 737 var aid = parseInt(article.getAttribute("data-aid"), 10); 738 var sh = state.shorts[aid]; 739 if (i === idx) { 740 article.classList.add("is-active"); 741 if (sh) { 742 if (sh.state === "ready" || sh.state === "idle") sh.play(); 743 else if (sh.state === "paused") sh.resume(); 744 fireViewedAfterDelay(aid, idx); 745 } 746 } else { 747 article.classList.remove("is-active"); 748 if (sh && sh.state === "playing") sh.pause(); 749 } 750 } 751 // Reflect playing state on pause-button 752 state.userPaused = false; 753 updatePauseBtnVisual(); 754 document.body.classList.remove("is-shorts-paused"); 755 } 756 function fireViewedAfterDelay(aid, idx) { 757 if (state.viewedTimers[aid]) clearTimeout(state.viewedTimers[aid]); 758 state.viewedTimers[aid] = setTimeout(function () { 759 state.sessionShortsViewed++; 760 addToList(SEEN_KEY, aid, 200); 761 var item = state.items.filter(function (i) { return i.id === aid; })[0]; 762 trackGoal("short_viewed", { 763 aid: aid, short_id: item ? item.short_id : "", 764 verdict: item ? item.verdict : "", 765 marketplace: item ? item.marketplace : "", 766 idx_in_session: idx, 767 }); 768 if (state.sessionShortsViewed === 5) trackGoal("feed_session_5plus", { count: 5 }); 769 }, 1000); 770 } 771 772 function loadNextBatch() { 773 if (state.loading || state.exhausted) return; 774 state.loading = true; 775 var url = "/api/shorts/feed?limit=7"; 776 if (state.cursor) url += "&cursor=" + encodeURIComponent(state.cursor); 777 var seen = getList(SEEN_KEY); 778 if (seen.length) url += "&seen=" + encodeURIComponent(seen.join(",")); 779 fetch(url, { headers: { Accept: "application/json" } }) 780 .then(function (r) { return r.ok ? r.json() : null; }) 781 .then(function (data) { 782 state.loading = false; 783 if (!data || !Array.isArray(data.items) || data.items.length === 0) { 784 state.exhausted = true; return; 785 } 786 var existing = {};
787 state.items.forEach(function (it) { existing[it.id] = true; }); 788 var fresh = data.items.filter(function (it) { return !existing[it.id]; }); 789 if (fresh.length === 0) { state.exhausted = !data.next_cursor; return; } 790 state.items = state.items.concat(fresh); 791 state.cursor = data.next_cursor; 792 if (!data.next_cursor) state.exhausted = true; 793 appendArticles(fresh); 794 if (state.embla) state.embla.reInit(); 795 }) 796 .catch(function (e) { 797 state.loading = false; 798 console.warn("[shorts] loadNextBatch failed", e); 799 }); 800 } 801 function appendArticles(items) { 802 var frag = document.createDocumentFragment(); 803 for (var i = 0; i < items.length; i++) { 804 frag.appendChild(buildArticleEl(items[i], false)); 805 } 806 trackEl.appendChild(frag); 807 } 808 809 /* ââ Hint âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 810 function showHintIfNeeded() { 811 if (isOnboarded() || !hintEl) return; 812 hintEl.removeAttribute("hidden"); 813 setTimeout(hideHint, 5000); 814 hintEl.addEventListener("click", function () { 815 hideHint(); markOnboarded(); 816 }); 817 } 818 function hideHint() { if (hintEl) hintEl.setAttribute("hidden", ""); } 819 820 /* ââ Mute toggle ââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 821 function setupMuteBtn() { 822 if (!muteBtn) return; 823 updateMuteBtnVisual(); 824 muteBtn.addEventListener("click", function () { 825 state.muted = !state.muted; 826 updateMuteBtnVisual(); 827 trackGoal(state.muted ? "audio_muted" : "audio_unmuted", {}); 828 }); 829 } 830 function updateMuteBtnVisual() { 831 if (!muteBtn) return; 832 if (state.muted) muteBtn.classList.add("is-muted"); 833 else muteBtn.classList.remove("is-muted"); 834 } 835 836 /* ââ Pause-button (explicit toggle in top-nav) ââââââââââââââââââââââââ */ 837 function setupPauseBtn() { 838 if (!pauseBtn) return; 839 updatePauseBtnVisual(); 840 pauseBtn.addEventListener("click", function (e) { 841 e.stopPropagation(); 842 toggleCurrentPause(); 843 }); 844 } 845 function updatePauseBtnVisual() { 846 if (!pauseBtn) return; 847 if (state.userPaused) pauseBtn.classList.add("is-paused"); 848 else pauseBtn.classList.remove("is-paused"); 849 pauseBtn.setAttribute("aria-label", state.userPaused ? "ÐÑодолжиÑÑ" : "ÐаÑза"); 850 } 851 852 /* ââ Auto-hide top-nav after settle âââââââââââââââââââââââââââââââââââ */ 853 function scheduleTopnavAutoHide() { 854 if (!topnavEl) return; 855 if (state.autoHideTimer) clearTimeout(state.autoHideTimer); 856 topnavEl.classList.remove("is-auto-hidden"); 857 if (PB_REDUCED) return; // never auto-hide if reduced motion 858 state.autoHideTimer = setTimeout(function () { 859 if (!state.userPaused) topnavEl.classList.add("is-auto-hidden"); 860 }, 1500); 861 } 862 function showTopnav() { 863 if (!topnavEl) return; 864 topnavEl.classList.remove("is-auto-hidden"); 865 } 866 867 /* ââ Tap-to-pause + double-tap-to-like ââââââââââââââââââââââââââââââââ */ 868 var lastTapTs = 0; 869 function setupTapInteractions() { 870 if (!viewportEl) return; 871 var tapTimer = null; 872 var touchStartTs = 0; 873 viewportEl.addEventListener("pointerdown", function (e) { 874 // v0.25.37: faster early-bail Ð´Ð»Ñ action-rail Ñаппов ÑеÑез [data-act] + 875 // single closest call. РанÑÑе 4 closest calls подÑÑд могли ÑоÑмозиÑÑ 876 // на slow devices â timer ÑÑаÑÑовал до bail â race Ñ button click. 877 // v0.25.39: еÑÑ ÑаÑÑиÑил â actions-stack Ñеликом + data-embla-no-drag 878 if (e.target && e.target.closest) { 879 if (e.target.closest("[data-act], [data-embla-no-drag], .pb-short-actions-stack, .pb-short-act-btn, .pb-short-act-mp, .pb-shorts-topnav, .pb-shorts-hint, .pb-short-caption-verdict-wrapper")) { 880 return; 881 } 882 } 883 touchStartTs = Date.now(); 884 tapTimer = setTimeout(function () { pauseCurrent(true); }, 320); 885 }); 886 viewportEl.addEventListener("pointerup", function (e) { 887 // v0.25.37/v0.25.39: early-bail пÑи tapе на action-rail. Также clear
888 // pending pause-timer ÑÑÐ¾Ð±Ñ Ð½Ðµ triggerнÑл pause поÑле button click. 889 if (e.target && e.target.closest) { 890 if (e.target.closest("[data-act], [data-embla-no-drag], .pb-short-actions-stack, .pb-short-act-btn, .pb-short-act-mp, .pb-shorts-topnav, .pb-shorts-hint, .pb-short-caption-verdict-wrapper")) { 891 if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; } 892 return; 893 } 894 } 895 if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; } 896 var dt = Date.now() - touchStartTs; 897 if (dt < 200) { 898 // v0.25.7: pause ÑабоÑÐ°ÐµÑ ÐÐÐÐÐÐÐÐÐ (ÑзеÑ: «на паÑÐ·Ñ ÑÑÐ°Ð·Ñ 899 // не ÑÑавиÑÑÑ»). РанÑÑе ждали 320ms на возможнÑй double-tap 900 // и ÑолÑко поÑом toggle pause â ÑÑо Ñоздавало lag. 901 // Now: single-tap â toggle pause INSTANT. Double-tap (еÑли 902 // в ÑеÑение 320ms еÑÑ Ð¾Ð´Ð¸Ð½ tap) â undo pause + fire like burst. 903 var now = Date.now(); 904 if (now - lastTapTs < 320) { 905 // Double-tap: undo пÑедÑдÑÑий pause (еÑли бÑл) + like burst 906 lastTapTs = 0; 907 // ÐÑли пÑедÑдÑÑий single-tap поÑÑавил на паÑÐ·Ñ â undo ÑÑо 908 if (state.shorts[currentAid()] && state.shorts[currentAid()].state === "paused") { 909 resumeCurrent(); 910 } 911 handleDoubleTap(); 912 } else { 913 // Single-tap â toggle pause IMMEDIATELY (без задеÑжки) 914 lastTapTs = now; 915 toggleCurrentPause(); 916 } 917 } else if (dt >= 320) { 918 resumeCurrent(); 919 } 920 }); 921 viewportEl.addEventListener("pointercancel", function () { 922 if (tapTimer) clearTimeout(tapTimer); tapTimer = null; 923 }); 924 } 925 function handleDoubleTap() { 926 var aid = currentAid(); 927 if (!aid) return; 928 var article = currentArticle(); 929 var item = state.items.filter(function (i) { return i.id === aid; })[0]; 930 if (!item || !article) return; 931 var likeBtn = article.querySelector('.pb-short-act-btn[data-act="like"]'); 932 if (!likeBtn) return; 933 if (!likeBtn.classList.contains("is-liked")) { 934 toggleLike(article, item, likeBtn); 935 } else { 936 fireBurst(article); 937 } 938 } 939 function pauseCurrent(longPress) { 940 var aid = currentAid(); if (!aid) return; 941 var sh = state.shorts[aid]; 942 if (sh && sh.state === "playing") { 943 sh.pause(); 944 state.userPaused = true; 945 if (typeof sh.setUserPaused === "function") sh.setUserPaused(true); 946 document.body.classList.add("is-shorts-paused"); 947 updatePauseBtnVisual(); 948 showTopnav(); 949 trackGoal("short_paused", { aid: aid, source: longPress ? "long_press" : "tap" }); 950 } 951 } 952 function resumeCurrent() { 953 var aid = currentAid(); if (!aid) return; 954 var sh = state.shorts[aid]; 955 if (sh && sh.state === "paused") { 956 sh.resume(); 957 state.userPaused = false; 958 if (typeof sh.setUserPaused === "function") sh.setUserPaused(false); 959 document.body.classList.remove("is-shorts-paused"); 960 updatePauseBtnVisual(); 961 scheduleTopnavAutoHide(); 962 trackGoal("short_resumed", { aid: aid }); 963 } 964 } 965 function toggleCurrentPause() { 966 var aid = currentAid(); if (!aid) return; 967 var sh = state.shorts[aid]; if (!sh) return; 968 if (sh.state === "playing") { 969 sh.pause(); 970 state.userPaused = true; 971 if (typeof sh.setUserPaused === "function") sh.setUserPaused(true); 972 document.body.classList.add("is-shorts-paused"); 973 updatePauseBtnVisual(); 974 showTopnav(); 975 trackGoal("short_paused", { aid: aid, source: "explicit_btn" }); 976 } else if (sh.state === "paused") { 977 sh.resume(); 978 state.userPaused = false; 979 if (typeof sh.setUserPaused === "function") sh.setUserPaused(false); 980 document.body.classList.remove("is-shorts-paused"); 981 updatePauseBtnVisual(); 982 scheduleTopnavAutoHide(); 983 trackGoal("short_resumed", { aid: aid, source: "explicit_btn" }); 984 } 985 } 986 function clearUserPausedAll() { 987 var articles = trackEl ? trackEl.querySelectorAll(".pb-short.is-user-paused") : []; 988 for (var i = 0; i < articles.length; i++) { 989 articles[i].classList.remove("is-user-paused"); 990 } 991 } 992 function currentArticle() { 993 var articles = trackEl.querySelectorAll(".pb-short"); 994 return articles[state.currentIdx] || null; 995 } 996 function currentAid() { 997 var article = currentArticle(); 998 if (!article) return null; 999 return parseInt(article.getAttribute("data-aid"), 10) || null; 1000 } 1001 1002 function setupVisibilityPause() { 1003 document.addEventListener("visibilitychange", function () { 1004 if (document.hidden) pauseCurrent(false); else resumeCurrent(); 1005 }); 1006 } 1007 1008 /* ââ Keyboard navigation (â/â scroll, Esc close, PgUp/PgDn 5x jump) âââââ */ 1009 function setupKeyboardNav() { 1010 document.addEventListener("keydown", function (e) { 1011 // Ignore when typing in inputs 1012 var t = e.target; 1013 if (t && t.tagName && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return; 1014 if (t && t.isContentEditable) return; 1015 1016 var key = e.key; 1017 if (!state.embla) return; 1018 1019 if (key === "ArrowDown" || key === "j") { 1020 e.preventDefault(); 1021 state.embla.scrollNext(); 1022 trackGoal("short_kbd_next", {}); 1023 } else if (key === "ArrowUp" || key === "k") { 1024 e.preventDefault(); 1025 state.embla.scrollPrev(); 1026 trackGoal("short_kbd_prev", {}); 1027 } else if (key === "Escape") { 1028 e.preventDefault(); 1029 trackGoal("short_kbd_close", {}); 1030 window.location.href = "/"; 1031 } else if (key === "PageDown") { 1032 e.preventDefault(); 1033 jumpBy(5); 1034 trackGoal("short_kbd_pgdn", {}); 1035 } else if (key === "PageUp") { 1036 e.preventDefault(); 1037 jumpBy(-5); 1038 trackGoal("short_kbd_pgup", {}); 1039 } else if (key === " " || key === "Space" || key === "Spacebar") { 1040 e.preventDefault(); 1041 toggleCurrentPause(); 1042 } else if (key === "m" || key === "M") { 1043 if (muteBtn) muteBtn.click(); 1044 } 1045 }); 1046 } 1047 function jumpBy(delta) { 1048 if (!state.embla) return; 1049 var snaps = state.embla.scrollSnapList ? state.embla.scrollSnapList() : null; 1050 var len = snaps ? snaps.length : state.items.length; 1051 var target = Math.max(0, Math.min(len - 1, state.currentIdx + delta)); 1052 if (typeof state.embla.scrollTo === "function") { 1053 state.embla.scrollTo(target, false); 1054 } 1055 } 1056 1057 /* ââ v0.25.11: Desktop mouse wheel navigation ââââââââââââââââââââââââââ 1058 ЮзеÑ: «ÑкÑоллинг на ÐРне ÑабоÑÐ°ÐµÑ ÑеÑез мÑÑкÑ». Embla не Ð¸Ð¼ÐµÐµÑ Ð²ÑÑÑоенного 1059 wheel-plugin (vendored UMD не вклÑÑÐ°ÐµÑ gestures). Now: ÑÑÑной handler Ñ 1060 600ms cooldown Ð¼ÐµÐ¶Ð´Ñ snap'ами + 30px deltaY threshold (анÑи-trackpad-noise). 1061 РабоÑÐ°ÐµÑ ÑолÑко когда target внÑÑÑи viewport (не блокиÑÑÐµÑ scroll вне). */ 1062 /* v0.25.41: SINGLE event-delegated click listener на viewport â atomic 1063 safety net повеÑÑ per-button addEventListener. ÐÑли по лÑбой пÑиÑине 1064 per-button handler не attached / mounted (race during virtualization, 1065 SSR cards без data-aid, и Ñ.д.) â ÑÑÐ¾Ñ ÑÑабоÑаеÑ. ÐÑблÑ-fire заÑиÑа 1066 ÑеÑез data-act-handled flag (set + reset в next tick). 1067 ЮзеÑ: Â«Ð½Ð°Ð¶Ð¸Ð¼Ð°Ñ Ð»Ð°Ð¹Ðº и ниÑего не пÑоиÑÑ Ð¾Ð´Ð¸Ñ» â ÑÑо поÑледний bullet. */ 1068 function setupActionDelegation() { 1069 if (!viewportEl) return; 1070 viewportEl.addEventListener("click", function (e) { 1071 var btn = e.target && e.target.closest && e.target.closest("[data-act]"); 1072 if (!btn) return; 1073 // ÐÑблÑ-заÑиÑа: еÑли per-button Ñже ÑÑабоÑал и помеÑил event, 1074 // skip delegated. ÐÑполÑзÑем event flag. 1075 if (e._pbActHandled) return; 1076 e._pbActHandled = true; 1077 var article = btn.closest(".pb-short"); 1078 if (!article) return; 1079 var aidStr = article.getAttribute("data-aid"); 1080 var aid = aidStr ? parseInt(aidStr, 10) : null; 1081 if (!aid) return; 1082 var item = null; 1083 for (var i = 0; i < state.items.length; i++) { 1084 if (state.items[i].id === aid) { item = state.items[i]; break; } 1085 } 1086 if (!item) return; 1087 var act = btn.getAttribute("data-act"); 1088 try { 1089 if (act === "like") toggleLike(article, item, btn); 1090 else if (act === "dislike") toggleDislike(article, item, btn); 1091 else if (act === "share") openShare(item); 1092 else if (act === "save") toggleSave(item, btn); 1093 else if (act === "report") { 1094 trackGoal("short_full_report_clicked", { 1095 aid: item.id, short_id: item.short_id, verdict: item.verdict 1096 }); 1097 } else if (act === "shop") { 1098 trackGoal("short_shop_clicked", { 1099 aid: item.id, short_id: item.short_id, marketplace: item.marketplace 1100 }); 1101 } 1102 } catch (err) { 1103 console.warn("[shorts] delegated click handler failed:", err); 1104 } 1105 }, false); 1106 } 1107 1108 function setupWheelNav() { 1109 if (!viewportEl || !state.embla) return; 1110 var wheelLockUntil = 0; 1111 // v0.25.15: passive: true â болÑÑе ÐÐ preventDefault на каждÑй wheel event. 1112 // РанÑÑе preventDefault вÑзÑвалÑÑ Ð½Ð° cooldown/threshold-miss â 60+ blocked 1113 // events/Ñек на trackpad â main thread freeze. body{position:fixed} + 1114 // html{overflow:hidden} Ñже блокиÑÑÑÑ bubble â preventDefault не нÑжен. 1115 viewportEl.addEventListener("wheel", function (ev) { 1116 if (ev.target && ev.target.closest && ev.target.closest(".pb-short-act-btn-rail-scrollable")) return; 1117 var t = ev.target; 1118 if (t && t.tagName && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return; 1119 var now = Date.now(); 1120 if (now < wheelLockUntil) return; // cooldown â silent skip 1121 if (Math.abs(ev.deltaY) < 30) return; // threshold â silent skip 1122 wheelLockUntil = now + 600; 1123 if (ev.deltaY > 0) state.embla.scrollNext(); 1124 else state.embla.scrollPrev(); 1125 }, { passive: true });
1126 } 1127 1128 /* ââ 3D mouse-tracking parallax (desktop fine-pointer only) ââââââââââââ 1129 v0.25.15: skip-on-scroll integration. РанÑÑе parallax пÑодолжал 1130 transform'иÑÑ track во вÑÐµÐ¼Ñ Embla scroll â конÑÐ»Ð¸ÐºÑ transforms + 1131 getBoundingClientRect spam = freeze. Now: isScrolling flag ÑÐ¸Ð½Ñ Ñон Ñ 1132 Embla scroll/settle events; pointermove no-op во вÑÐµÐ¼Ñ ÑкÑолла. */ 1133 function setupParallax() { 1134 if (!viewportEl || !trackEl) return; 1135 if (PB_COARSE || PB_LOW_END || PB_REDUCED) return; 1136 1137 viewportEl.classList.add("is-parallax"); 1138 1139 var maxRX = 4, maxRY = 6; 1140 var rafId = null; 1141 var pendingX = 0, pendingY = 0; 1142 var hasFrame = false; 1143 var lastTs = 0; 1144 var isScrolling = false; 1145 1146 // v0.25.15: hook Embla scroll/settle â freeze parallax during swipe 1147 if (state.embla) { 1148 state.embla.on("scroll", function () { isScrolling = true; }); 1149 state.embla.on("settle", function () { isScrolling = false; }); 1150 } 1151 1152 function applyTransform() { 1153 hasFrame = false; 1154 if (!trackEl) return; 1155 trackEl.style.setProperty("--rx", pendingX.toFixed(2) + "deg"); 1156 trackEl.style.setProperty("--ry", pendingY.toFixed(2) + "deg"); 1157 } 1158 1159 function onMove(e) { 1160 if (state.userPaused || isScrolling) return; 1161 var rect = viewportEl.getBoundingClientRect(); 1162 var cx = rect.left + rect.width / 2; 1163 var cy = rect.top + rect.height / 2; 1164 var dx = (e.clientX - cx) / (rect.width / 2); 1165 var dy = (e.clientY - cy) / (rect.height / 2); 1166 if (dx < -1) dx = -1; else if (dx > 1) dx = 1; 1167 if (dy < -1) dy = -1; else if (dy > 1) dy = 1; 1168 pendingY = dx * maxRY; 1169 pendingX = -dy * maxRX; 1170 var now = performance.now ? performance.now() : Date.now(); 1171 if (!hasFrame && (now - lastTs) >= 16) { 1172 hasFrame = true; 1173 lastTs = now; 1174 rafId = requestAnimationFrame(applyTransform); 1175 } 1176 } 1177 function onLeave() { 1178 pendingX = 0; pendingY = 0; 1179 if (rafId) cancelAnimationFrame(rafId); 1180 rafId = null; 1181 hasFrame = false; 1182 if (trackEl) { 1183 trackEl.style.setProperty("--rx", "0deg"); 1184 trackEl.style.setProperty("--ry", "0deg"); 1185 } 1186 } 1187 1188 document.addEventListener("pointermove", function (e) { 1189 if (e.pointerType && e.pointerType === "touch") return; 1190 onMove(e); 1191 }, { passive: true }); 1192 1193 document.addEventListener("pointerleave", onLeave, { passive: true }); 1194 window.addEventListener("blur", onLeave, { passive: true }); 1195 } 1196 1197 /* ââ Init âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 1198 function init() { 1199 trackEl = $("pbShortsTrack"); 1200 viewportEl = $("pbShortsViewport"); 1201 progressEl = $("pb-shorts-progress"); 1202 hintEl = $("pb-shorts-hint"); 1203 muteBtn = $("pb-shorts-mute-btn"); 1204 pauseBtn = $("pb-shorts-pause-btn"); 1205 topnavEl = document.querySelector(".pb-shorts-topnav"); 1206 1207 if (!trackEl || !viewportEl) { 1208 console.warn("[shorts] missing DOM nodes"); 1209 return; 1210 } 1211 1212 if (!PB_LOW_END) viewportEl.classList.add("is-cinematic"); 1213 else viewportEl.classList.add("is-low-end"); 1214 1215 var bootstrap = readBootstrap(); 1216 state.items = (bootstrap.items || []).slice(); 1217 state.cursor = bootstrap.next_cursor || null; 1218 state.exhausted = !state.cursor; 1219 1220 if (state.items.length === 0) { 1221 trackEl.innerHTML = 1222 '<article class="pb-short" style="display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);text-align:center;padding:32px">' + 1223 '<div><div style="font-size:18px;margin-bottom:12px;color:#fff;font-weight:700">РаÑÑледований пока неÑ</div>' + 1224 '<div style="font-size:14px">ÐÐ°Ñ Ð¾Ð´Ð¸ позже или <a href="/" style="color:#4ce2c1;font-weight:700">пÑовеÑÑ Ñвой ÑÐ¾Ð²Ð°Ñ â</a></div></div>' + 1225 '</article>'; 1226 return; 1227 } 1228 1229 var existing = trackEl.querySelectorAll(".pb-short").length; 1230 if (state.items.length > existing) { 1231 appendArticles(state.items.slice(existing)); 1232 } 1233 1234 // Inject pause-indicator and ensure CSS vars on track for any SSR pre-rendered articles 1235 // v0.25.11: ТÐÐÐÐ wire-up action handlers Ð´Ð»Ñ SSR cards (ÑанÑÑе пеÑвÑе 2 1236 // каÑÑоÑки имели мÑÑÑвÑе кнопки â ÑзеÑ: «в ленÑе кнопки не ÑабоÑаÑÑ»). 1237 var ssrArticles = trackEl.querySelectorAll(".pb-short"); 1238 for (var i = 0; i < ssrArticles.length; i++) { 1239 var art = ssrArticles[i]; 1240 if (!art.querySelector(".pb-short-pause-indicator")) { 1241 var pi = document.createElement("div"); 1242 pi.className = "pb-short-pause-indicator"; 1243 pi.setAttribute("aria-hidden", "true"); 1244 pi.innerHTML = 1245 '<svg viewBox="0 0 24 24" aria-hidden="true">' + 1246 '<rect x="6" y="5" width="4" height="14" rx="1.2"/>' + 1247 '<rect x="14" y="5" width="4" height="14" rx="1.2"/>' + 1248 '</svg>'; 1249 art.appendChild(pi); 1250 } 1251 // v0.25.11: like-burst еÑли оÑÑÑÑÑÑвÑÐµÑ 1252 if (!art.querySelector(".pb-short-like-burst")) { 1253 var burst = document.createElement("div"); 1254 burst.className = "pb-short-like-burst"; 1255 burst.setAttribute("aria-hidden", "true"); 1256 burst.innerHTML = 1257 '<svg viewBox="0 0 24 24">
1257<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>'; 1258 art.appendChild(burst); 1259 } 1260 // v0.25.11: wire SSR action-rail handlers â найÑи ÑооÑвеÑÑÑвÑÑÑий item 1261 // в state.items по data-aid + вÑзваÑÑ attachActionHandlers 1262 var aidStr = art.getAttribute("data-aid"); 1263 if (aidStr) { 1264 var aidNum = parseInt(aidStr, 10); 1265 var matchedItem = null; 1266 for (var mi = 0; mi < state.items.length; mi++) { 1267 if (state.items[mi].id === aidNum) { 1268 matchedItem = state.items[mi]; 1269 break; 1270 } 1271 } 1272 if (matchedItem) { 1273 attachActionHandlers(art, matchedItem); 1274 } 1275 } 1276 } 1277 // v0.25.11: body scroll-lock + html lock â гаÑанÑиÑÑÐµÑ ÑÑо page не ÑкÑолиÑÑÑ 1278 // когда ÑÐ·ÐµÑ ÑÐ²Ð°Ð¹Ð¿Ð°ÐµÑ feed (ÑзеÑ: «ленÑа вмеÑÑе ÑкÑолиÑÑÑ»). 1279 try { 1280 document.documentElement.classList.add("pb-shorts-html-active"); 1281 document.body.classList.add("pb-shorts-body"); 1282 } catch (_) {} 1283 // Initialize transform vars 1284 if (trackEl) { 1285 trackEl.style.setProperty("--rx", "0deg"); 1286 trackEl.style.setProperty("--ry", "0deg"); 1287 } 1288 1289 state.embla = initEmbla(); 1290 if (!state.embla) console.warn("[shorts] Embla init failed"); 1291 1292 setupMuteBtn(); 1293 setupPauseBtn(); 1294 setupTapInteractions(); 1295 setupKeyboardNav(); 1296 setupVisibilityPause(); 1297 setupParallax(); 1298 setupWheelNav(); // v0.25.11: desktop mouse wheel navigation 1299 setupActionDelegation(); // v0.25.41: atomic event delegation Ð´Ð»Ñ action-rail 1300 1301 trackGoal("feed_open", { 1302 entry: window.location.pathname.indexOf("/shorts/") === 0 ? "deeplink" : "organic", 1303 }); 1304 1305 virtualize(0); 1306 playCurrentShort(0); 1307 scheduleTopnavAutoHide(); 1308 1309 if (!isOnboarded()) setTimeout(showHintIfNeeded, 1200); 1310 1311 window.addEventListener("beforeunload", function () { 1312 trackGoal("feed_session_drop_off", { 1313 last_idx: state.currentIdx, 1314 total_dwell_ms: Date.now() - state.sessionStarted, 1315 shorts_viewed: state.sessionShortsViewed, 1316 }); 1317 }); 1318 } 1319 1320 if (document.readyState === "loading") { 1321 document.addEventListener("DOMContentLoaded", init); 1322 } else { 1323 setTimeout(init, 50); 1324 } 1325})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.