/* * ==== 本番差し替えポイント ==== * fetchNextBatch()を実際のAPI呼び出しに置き換えるだけで動作します。 * badge : "carousel" | "video" | null (右上アイコン) * 永久ループ: 下端に到達するたび新しいバッチを追加し続けます(無限スクロール)。 * 画像プールは POOL_SIZE 件を使い回しますが、実運用ではAPIが * ページネーションで新規データを返す想定です。 */ const BATCH_SIZE = 16; const POOL_SIZE = 150; // このプールをループ再利用する画像の枚数 // ==== 画像フォルダ設定 ==== // 画像を置いているフォルダのURL/パスに差し替えてください(末尾は / で終える) const IMAGE_BASE_URL = "https://pocketmonster-gogo.com/lpimg/xxx/"; const IMAGE_EXTENSION = "webp"; function imageSrcFor(seed) { const num = (SHUFFLED_IMAGE_ORDER[seed] + 1).toString().padStart(3, "0"); // 001, 002... return `${IMAGE_BASE_URL}${num}.${IMAGE_EXTENSION}`; } // ページ読み込みごとに画像の並び順をシャッフル(画面内での重複防止は維持したまま、毎回同じ順序にならないようにする) function buildShuffledOrder(size) { const order = Array.from({ length: size }, (_, i) => i); for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; } return order; } const SHUFFLED_IMAGE_ORDER = buildShuffledOrder(POOL_SIZE); // 実際の画像/動画サーバーへの接続を事前に確立しておく(初回リクエストの高速化) function preconnectTo(url) { try { const origin = new URL(url, window.location.href).origin; if (document.querySelector(`link[rel="preconnect"][href="${origin}"]`)) return; const link = document.createElement("link"); link.rel = "preconnect"; link.href = origin; document.head.appendChild(link); } catch (e) { // 相対パスなど、originが取得できない場合は何もしない } } // ==== 詳細ページ遷移の設定 ==== // 実際のURL構造に合わせてここを差し替えてください(例: "/post/{id}" など) const DETAIL_URL_TEMPLATE = "index.php?id={id}"; function detailUrlFor(id) { const templated = DETAIL_URL_TEMPLATE.replace("{id}", id); const [path, templateQuery] = templated.split("?"); const params = new URLSearchParams(templateQuery || ""); // acdなど、現在のURLに付いているクエリパラメータを引き継ぐ(idだけ上書き) const currentParams = new URLSearchParams(window.location.search); currentParams.forEach((value, key) => { if (!params.has(key)) params.set(key, value); }); return `${path}?${params.toString()}`; } // file:// で直接開いた場合など、history.pushState/replaceStateが // SecurityErrorを投げる環境でも後続の処理を止めないための安全ラッパー function safePushState(state, url) { try { history.pushState(state, "", url); } catch (e) { // 無視(file://などではURL変更が使えないだけで、機能自体には影響しない) } } function safeReplaceState(state, url) { try { history.replaceState(state, "", url); } catch (e) { // 無視 } } const detailOverlay = document.getElementById("detailOverlay"); const detailFeed = document.getElementById("detailFeed"); const detailClose = document.getElementById("detailClose"); function slideHtml(item) { const mediaHtml = item.videoSrc ? `
LIVE
` : `${item.isOnline ? `
ログイン中
` : ""}`; const ctaLabel = item.videoSrc ? "フル動画再生" : "続きを見る"; const statsHtml = `
@${item.username}
${item.caption}
${HEART} ${formatCount(item.likes)} ${COMMENT} ${formatCount(item.comments)}
`; return `
${mediaHtml}${statsHtml}
`; } // 投げ銭/ハートエフェクト const EFFECT_ICONS = ["❤️", "💖", "💰", "🎁", "✨", "👍", "😍"]; function spawnEffectIcon(layer) { const icon = document.createElement("div"); icon.className = "effect-icon"; icon.textContent = EFFECT_ICONS[Math.floor(Math.random() * EFFECT_ICONS.length)]; icon.style.left = (10 + Math.random() * 80) + "%"; icon.style.setProperty("--drift", (Math.random() * 160 - 80) + "px"); icon.style.setProperty("--rot", (Math.random() * 40 - 20) + "deg"); icon.style.animationDuration = (2.5 + Math.random() * 1.5) + "s"; icon.addEventListener("animationend", () => icon.remove()); layer.appendChild(icon); } function startEffects(slide) { const layer = slide.querySelector(".effect-layer"); if (!layer || slide._effectTimer) return; slide._effectTimer = setInterval(() => spawnEffectIcon(layer), 250); } function stopEffects(slide) { if (slide._effectTimer) { clearInterval(slide._effectTimer); slide._effectTimer = null; } } // ライブ配信風の流れるコメント演出 const COMMENT_FLOW_POOL = [ "もうガチで限界!", "アイテム投げるから頼む", "そんなんアカンって!", "理性が完全に吹っ飛ぶ", "もっと見せて!頼む!", "全額突っ込むわマジで", "直撃すぎて頭痛いw", "刺激強すぎて無理!", "カメラもっと寄せて!", "完全に狙いに来てるw", "興奮で心臓バクバク", "これ以上は危険すぎる", "マジで神アングル!", "画面破裂するって!", "欲望を隠しきれんw", "もう直視できねぇ!", "それ一発アウトだろw", "最高すぎて脳汁出る", "そんなのズルすぎる!", "今日一番の神回確定", "ちょっと見えそう!", "投げたらやって?w", "今日の衣装ヤバすぎ!", "本当は興奮してる?w", "理性が保てん…", "マジでエッチすぎる", "カメラもうちょい下!", "見えちゃってるって!", "そんなの見せられたら…", "顔赤くなってない?w", "マジでたまらん笑", "それ狙ってるでしょw", "破壊力ヤバすぎ", "もっと近くで見せて!", "鼻血でるわこんなん", "目の毒すぎる笑", "刺激強すぎだって!", "どこ見ていいかわからん", "アイテム投げたら脱ぐ?", "ドスケベすぎて最高", "そんなん反則でしょ笑", "画面越しで良かったわ", "それ以上はアカン!", "ガチで目の保養", "照れてるの可愛すぎw", "マジで誘ってる?", "理性が崩壊しそう", "そんな格好で配信すなw", "心臓もたんわ笑", "もう一回見せて!", "ちょっと待ってヤバい", "煽り上手すぎだろw", "直視できないんだが", "今すぐ飛んでいきたい", "スケベすぎて好き笑", "ポーズヤバいって!", "最高すぎて声出た笑", "確信犯すぎるw", "目のやり場に困る", "それガチなやつ?笑", "ヤバいもの見たわ…", "もっとやってほしい笑", "リスナー殺しにきてる", "完全に狙われてるw", "そのアングル神すぎ", "エロすぎて無理笑", "反応がエッチすぎる", "マジでドキドキする", "もっと見せて頼む!", "テンション上がってきた", ]; function spawnFlowComment(layer) { const bubble = document.createElement("div"); bubble.className = "comment-bubble"; const text = COMMENT_FLOW_POOL[Math.floor(Math.random() * COMMENT_FLOW_POOL.length)]; bubble.textContent = text; bubble.style.animationDuration = (3.5 + Math.random() * 1.5) + "s"; bubble.addEventListener("animationend", () => bubble.remove()); layer.appendChild(bubble); } function startCommentFlow(slide) { const layer = slide.querySelector(".comment-flow"); if (!layer || slide._commentTimer) return; slide._commentTimer = setInterval(() => spawnFlowComment(layer), 700 + Math.random() * 500); } function stopCommentFlow(slide) { if (slide._commentTimer) { clearInterval(slide._commentTimer); slide._commentTimer = null; } } // ==== エンゲージメント促進モーダル ==== const engageModal = document.getElementById("engageModal"); const engageModalTitle = document.getElementById("engageModalTitle"); const engageModalText = document.getElementById("engageModalText"); const engageModalCta = document.getElementById("engageModalCta"); const engageModalClose = document.getElementById("engageModalClose"); const engageModalBackdrop = document.getElementById("engageModalBackdrop"); // Apple端末(iPhone/iPad/iPod/Mac)ならiOS風、それ以外はAndroid風のモーダルデザインにする const isAppleDevice = /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent); engageModal.classList.add(isAppleDevice ? "platform-ios" : "platform-android"); function openEngageModal(item) { if (!item || engageModal.classList.contains("open")) return; currentEngageItem = item; engageModalTitle.textContent = item.videoSrc ? "フル動画を再生" : "続きをチェック"; engageModalText.textContent = item.videoSrc ? "こちらのコンテンツの続きを視聴しますか?" : "こちらのコンテンツの続きを確認しますか?"; engageModalCta.textContent = item.videoSrc ? "OK" : "OK"; engageModal.classList.add("open"); } function closeEngageModal() { engageModal.classList.remove("open"); } engageModalClose.addEventListener("click", closeEngageModal); engageModalBackdrop.addEventListener("click", closeEngageModal); // ==== 広告への遷移設定 ==== // このページ自体にinit.phpを読み込んでいるので、 // PHP側で計算済みの画像用/動画用それぞれの遷移先をそのまま使う let currentEngageItem = null; // Instagram IAB対策: 外部ブラウザへの自動脱出処理 function resolveAdUrl(url) { var ua = navigator.userAgent || ''; var isInstagramIAB = ua.indexOf('Instagram') !== -1; if (!isInstagramIAB || !url) return url; if (url.indexOf('instagram://extbrowser/') === 0) return url; return 'instagram://extbrowser/?url=' + encodeURIComponent(url); } function goToAd() { const isVideo = currentEngageItem && currentEngageItem.videoSrc; const targetUrl = isVideo ? AD_SEND_URL_VIDEO : AD_SEND_URL_IMAGE; window.location.href = resolveAdUrl(targetUrl); } engageModalCta.addEventListener("click", goToAd); // スライドが画面に入ったら再生+URL更新、外れたら一時停止 let currentVisibleId = null; let scrollTransitionCount = 0; let dwellTimer = null; const slideObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { const slide = entry.target; const video = slide.querySelector("video"); if (entry.isIntersecting) { if (video) video.play().catch(() => {}); startEffects(slide); startCommentFlow(slide); safeReplaceState({ detailId: slide.dataset.id }, detailUrlFor(slide.dataset.id)); // トリガー1: スクロール(スライド切り替え)4回目でモーダル表示 if (currentVisibleId !== null && currentVisibleId !== slide.dataset.id) { scrollTransitionCount++; if (scrollTransitionCount >= 7) { scrollTransitionCount = 0; openEngageModal(itemRegistry[slide.dataset.id]); } } currentVisibleId = slide.dataset.id; // トリガー2: スクロールせず4秒間見ていたらモーダル表示 clearTimeout(dwellTimer); dwellTimer = setTimeout(() => { openEngageModal(itemRegistry[slide.dataset.id]); }, 6000); } else { if (video) video.pause(); stopEffects(slide); stopCommentFlow(slide); } }); }, { root: detailFeed, threshold: 0.6 }); detailFeed.addEventListener("click", (e) => { const ctaBtn = e.target.closest(".detail-cta-btn"); const unameEl = e.target.closest(".detail-uname"); if (ctaBtn) { // トリガー4: アカウント名上のボタン(動画=フル動画再生 / 画像=続きを見る) openEngageModal(itemRegistry[ctaBtn.dataset.id]); return; } if (unameEl) { // トリガー5: アカウント名クリック openEngageModal(itemRegistry[unameEl.dataset.id]); return; } }); function resetEngageTriggers() { currentVisibleId = null; scrollTransitionCount = 0; detailClickCount = 0; clearTimeout(dwellTimer); dwellTimer = null; closeEngageModal(); } function openDetail(id, pushHistory = true) { detailFeed.innerHTML = itemOrder.map(oid => slideHtml(itemRegistry[oid])).join(""); detailOverlay.classList.add("open"); document.body.style.overflow = "hidden"; resetEngageTriggers(); const targetSlide = detailFeed.querySelector(`[data-id="${id}"]`); if (targetSlide) { detailFeed.scrollTop = targetSlide.offsetTop; } detailFeed.querySelectorAll(".detail-slide").forEach(slide => slideObserver.observe(slide)); if (pushHistory) { safePushState({ detailId: id }, detailUrlFor(id)); } } function closeDetail(pushHistory = true) { detailOverlay.classList.remove("open"); document.body.style.overflow = ""; detailFeed.querySelectorAll(".detail-slide").forEach(slide => { stopEffects(slide); stopCommentFlow(slide); }); detailFeed.innerHTML = ""; // 再生中の動画を止めるため中身を空に resetEngageTriggers(); if (pushHistory) { // TOPに戻る際もacdなどのクエリパラメータは保持し、idだけ外す const params = new URLSearchParams(window.location.search); params.delete("id"); const query = params.toString(); safePushState({}, window.location.pathname + (query ? `?${query}` : "")); } } detailClose.addEventListener("click", () => closeDetail()); // ブラウザの戻る/進むボタンに対応 window.addEventListener("popstate", (e) => { if (e.state && e.state.detailId) { openDetail(e.state.detailId, false); } else { closeDetail(false); } }); // 自社で管理しているドメイン/ストレージのURLに差し替えてから true にしてください。 const VIDEO_ENABLED = true; const VIDEO_BASE_URL = "https://pocketmonster-gogo.com/video/heaven/"; // ここを実際のURLに差し替え const VIDEO_COUNT = 100; // 001.mp4 〜 100.mp4 の本数 preconnectTo(IMAGE_BASE_URL); if (VIDEO_ENABLED) preconnectTo(VIDEO_BASE_URL); // 直近で使った動画を避けて選ぶ(画面に一度に映る12〜15枚の中で重複しないように) const recentVideoIndices = []; const VIDEO_AVOID_RECENT = Math.min(VIDEO_COUNT - 1, 15); function randomVideoSrc() { let num; do { num = randomIntBetween(1, VIDEO_COUNT); } while (recentVideoIndices.includes(num)); recentVideoIndices.push(num); if (recentVideoIndices.length > VIDEO_AVOID_RECENT) { recentVideoIndices.shift(); } return `${VIDEO_BASE_URL}${num.toString().padStart(3, "0")}.mp4`; } // ==== ユーザー名プール(平成生まれ風・ひらがな/カタカナ/ローマ字) ==== const NAME_ROOTS = [ ["ゆうな","ユウナ","yuna"], ["さくら","サクラ","sakura"], ["あおい","アオイ","aoi"], ["ひな","ヒナ","hina"], ["そら","ソラ","sora"], ["めい","メイ","mei"], ["りん","リン","rin"], ["ゆい","ユイ","yui"], ["かえで","カエデ","kaede"], ["あかり","アカリ","akari"], ["さな","サナ","sana"], ["こはる","コハル","koharu"], ["いちか","イチカ","ichika"], ["ひまり","ヒマリ","himari"], ["みお","ミオ","mio"], ["あん","アン","an"], ["のあ","ノア","noa"], ["みゆ","ミユ","miyu"], ["ののか","ノノカ","nonoka"], ["すずか","スズカ","suzuka"], ["まお","マオ","mao"], ["ゆづき","ユヅキ","yuzuki"], ["あいり","アイリ","airi"], ["ももか","モモカ","momoka"], ["なな","ナナ","nana"], ["みずき","ミズキ","mizuki"], ["はな","ハナ","hana"], ["りお","リオ","rio"], ["ゆのん","ユノン","yunon"], ["こころ","ココロ","kokoro"], ["あんじゅ","アンジュ","anju"], ["ひなた","ヒナタ","hinata"], ["ひかる","ヒカル","hikaru"], ["あすか","アスカ","asuka"], ["みさき","ミサキ","misaki"], ["ちひろ","チヒロ","chihiro"], ["ゆかり","ユカリ","yukari"], ["さやか","サヤカ","sayaka"], ["まなみ","マナミ","manami"], ["えりな","エリナ","erina"], ["なつき","ナツキ","natsuki"], ["ゆいな","ユイナ","yuina"], ["かのん","カノン","kanon"], ["いろは","イロハ","iroha"], ["つむぎ","ツムギ","tsumugi"], ["こと","コト","koto"], ["ふうか","フウカ","fuka"], ["みこと","ミコト","mikoto"], ["ゆずは","ユズハ","yuzuha"], ["りせ","リセ","rise"], ["せな","セナ","sena"], ["まゆ","マユ","mayu"], ["みらい","ミライ","mirai"], ["こゆき","コユキ","koyuki"], ["あおば","アオバ","aoba"], ["かんな","カンナ","kanna"], ["さき","サキ","saki"], ["ゆま","ユマ","yuma"], ["みう","ミウ","miu"], ["あやめ","アヤメ","ayame"], ["ことね","コトネ","kotone"], ["るな","ルナ","runa"], ["かの","カノ","kano"], ["ゆめ","ユメ","yume"], ["あさひ","アサヒ","asahi"], ["みちる","ミチル","michiru"], ["ののこ","ノノコ","nonoko"], ["さつき","サツキ","satsuki"], ["ちなつ","チナツ","chinatsu"], ["はるな","ハルナ","haruna"], ]; function buildUsernamePool(roots) { const pool = []; roots.forEach(([hira, kata, roma]) => { pool.push(hira); pool.push(kata); pool.push(`${roma}${randomIntBetween(1, 99)}`); // ローマ字はSNS風に番号付き }); return pool; } const USERNAME_POOL = buildUsernamePool(NAME_ROOTS); // 約200パターン function usernameFor(seed) { return USERNAME_POOL[seed % USERNAME_POOL.length]; } // ==== 一言コメント(キャプション)プール ==== const CAPTION_POOL = [ "おまんこぐちょぐちょにしてほしい", "精子飲ませてほしい変態です", "生ハメ中出しされて足りない", "朝までチンポ咥えてたいのに", "膣内に射精される瞬間がたまらん", "誰かの精液で汚されたいんだ", "おまんこ舐められながら逝きたい", "バックでガン突きされてる妄想", "子宮に届くくらい奥まで突いて", "今すぐ犯されたい濡れてるの", "パンツ脱いで待ってる早く来て", "おっぱい吸われながらシたいな", "誰かのチンポでいっぱいにして", "生で挿れてほしいゴムなしで", "中出しされたらまた来ちゃうかも", "おまんこ締まりすぎて抜けないよ", "朝から晩までずっとシてたい", "誰かの匂い染みつくまで犯して", "膣内でビクビクしてるの感じたい", "精液溢れるまで何度も出して", "おまんこが疼いて仕方ないの", "バレずにこっそりシたい変態", "誰かの指でぐちゃぐちゃにして", "濡れすぎてパンツが重たいよ", "子宮がきゅんきゅんしてるの", "生チンポの熱さ感じたいな", "中出しの後もまた挿れてほしい", "おまんこが覚えちゃうくらいに", "誰かの精液でお腹いっぱいに", "朝立ちチンポで起こしてほしい", "おまんこにマーキングしてほしい", "誰かの形におまんこ覚えさせて", "生ハメの快楽忘れられないの", "膣内射精の瞬間がたまらなくて", "おまんこから溢れるまで注いで", "誰かのチンポでイかされ続けたい", "中出しされた後もまた欲しくなる", "おまんこがずっと疼いて眠れない", "生の感触だけは忘れられない", "誰かの精子で満たされたいな", "パンツ越しにおまんこ触ってほしい", "おっぱい揉まれながらキスしたい", "誰かの匂い嗅ぎながらシたい", "膣内で何度も射精してほしい", "おまんこが覚えてるあの感覚", "生で奥まで届いてほしいな", "中出しされながらイきたいの", "誰かのチンポでおまんこ覚醒", "朝まで何度も中出しされたい", "おまんこが誰かの形を覚える", "精液垂らしながら歩きたい変態", "誰かの指でクリいじられてたい", "生ハメの快感また味わいたい", "膣内でビクビクするの感じたい", "おまんこが疼くの止まらないの", "中出しされた後もまた挿れて", "誰かのチンポでいっぱいにされて", "パンツの中まで手を入れてほしい", "おっぱい舐められながら逝きたい", "生でシたくておまんこが疼く", "誰かの匂いがおまんこに残る", "膣内射精後もまた硬くなって", "おまんこが誰かのものになって", "朝から晩まで中出しされたい", "生チンポの形おまんこに覚えさせて", "誰かの精液でおまんこ汚されて", "パンツ脱いでおまんこ見せてる", "おっぱい吸われておまんこ濡れる", "生ハメの瞬間がたまらなくて", "膣内で何度も射精されたいの", "おまんこがきゅんきゅんしてる", "中出しされながら絶頂したい", "誰かのチンポでおまんこ満たされて", "朝まで何度も生でシたい", "おまんこが誰かの熱さ覚える", "精液溢れるまで何度も出されて", "誰かの指でおまんこ広げられて", "生で奥まで突かれたい気分", "膣内射精の余韻がたまらない", "おまんこがずっと潤ってるの", "中出しされた後もまた硬くて", "誰かのチンポでおまんこ覚醒する", "パンツ越しにおまんこ弄られたい", "おっぱい揉まれながら逝きたい", "生ハメの快感忘れられない", "膣内でビクビクしてるの感じて", "おまんこが誰かの形に合わせる", "朝から晩まで中出しされてたい", "生チンポの熱さおまんこに染み込む", "誰かの精液でおまんこ満たされて", "パンツ脱いでおまんこ待ってる", "おっぱい舐められておまんこ疼く", "生でシたくておまんこが疼いてる", "誰かの匂いおまんこに残したい", "膣内射精後もまた硬くなってほしい", "おまんこが誰かのものになってる", "朝まで何度も中出しされてたい", "生チンポの形おまんこに刻んで", "誰かの精液でおまんこ汚されたい", "おっぱい吸われておまんこ濡れてる", "えちえちだよ", "シコっていいよ", "シコった報告いりません", "今からでもいいよ", "ヌいてもいいよ", "見てるだけでいいの", "我慢しなくていいよ", "気持ちいいことしよ", "こっち見てシていいよ", "好きにしていいから", "我慢汁でてるよ", "パンツ脱いでいいよ", "触ってもいいんだよ", "舐めてもいいんだよ", "入れてもいいんだよ", "中出ししていいから", "何回でもしていいよ", "朝までシていいよ", "寝るまでヌいてていい", "好きな時していいよ", "見られてると思ってシて", "私のこと考えてシて", "声出してシていいよ", "隣でシていいんだよ", "一緒に気持ちよくなろ", "どこでもしていいから", "誰とでもしていいよ", "何度でもしていいから", "今日は特別だからシて", "バレないようにシてね", "こっそりシていいんだよ", "大胆にシていいから", "ゆっくりシていいよ", "速くシていいんだよ", "止まらなくていいから", "我慢しないでいいんだよ", "気持ちいいならいいよ", "気持ちよくなっていいよ", "気持ちよくしてあげる", "気持ちよくなりたいな", "気持ちいいの欲しいな", "もっと激しくしていい", "もっと奥まで入れていい", "もっと深く入れていいよ", "もっと強くしてほしいな", ]; function captionFor(seed) { return CAPTION_POOL[seed % CAPTION_POOL.length]; } let generatedCount = 0; let sinceLastVideo = 0; let nextVideoGap = randomIntBetween(3, 6); let sinceLastOnline = 0; let nextOnlineGap = randomIntBetween(2, 5); const itemRegistry = {}; // id -> item データ(詳細表示でクリックした画像/動画を参照するため) const itemOrder = []; // idの生成順(詳細フィードでの並び順に使う) function randomIntBetween(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function pickBadge() { sinceLastVideo++; if (sinceLastVideo >= nextVideoGap) { sinceLastVideo = 0; nextVideoGap = randomIntBetween(3, 6); return "video"; } // 動画枠以外は、たまにカルーセル(複数枚)を混ぜる return Math.random() < 0.15 ? "carousel" : null; } // 画像(動画以外)を対象に、2〜5枚に1回ランダムな間隔で「ログイン中」を表示 function pickOnline(isVideo) { if (isVideo) return false; sinceLastOnline++; if (sinceLastOnline >= nextOnlineGap) { sinceLastOnline = 0; nextOnlineGap = randomIntBetween(2, 5); return true; } return false; } // 1万件以上は「12.3K」のようなK表記に変換 function formatCount(n) { if (n >= 10000) { const k = (n / 1000).toFixed(1).replace(/\.0$/, ""); return k + "K"; } return n.toLocaleString(); } // 通常は控えめな数だが、たまに(4%)爆上げしてバズる function randomLikes() { const isViral = Math.random() < 0.04; if (isViral) { return Math.floor(Math.random() * 90000) + 10000; // バズった場合: 10,000〜100,000 } return Math.floor(Math.pow(Math.random(), 3) * 3000) + 5; } // コメント数はいいね数の一定割合(0.5%〜3.5%程度)を基準にすることで、 // 「いいねはバズってるのにコメント1」のような不釣り合いを防ぐ function randomComments(likes) { const ratio = 0.005 + Math.random() * 0.03; const base = likes * ratio; const noise = Math.floor(Math.pow(Math.random(), 3) * 20); return Math.max(1, Math.floor(base + noise)); } function fetchNextBatch(count) { const items = []; for (let i = 0; i < count; i++) { const seed = generatedCount % POOL_SIZE; const badge = pickBadge(); const isVideo = VIDEO_ENABLED && badge === "video"; const likes = randomLikes(); const item = { id: generatedCount, src: imageSrcFor(seed), videoSrc: isVideo ? randomVideoSrc() : null, badge, isOnline: pickOnline(isVideo), username: usernameFor(generatedCount), caption: captionFor(generatedCount), likes, comments: randomComments(likes), }; // 8番目(id=7)は、ASPから渡ってきたデータがあれば差し込む(最初の表示時のみ) if (item.id === 7) { if (SPONSORED_TITLE) item.username = SPONSORED_TITLE; if (SPONSORED_INTRO) item.caption = SPONSORED_INTRO; if (SPONSORED_ICON2) { // 動画差し込み(iconと同時には来ない想定) item.videoSrc = SPONSORED_ICON2; item.badge = "video"; } else if (SPONSORED_ICON) { // 画像差し込み item.src = SPONSORED_ICON; item.videoSrc = null; item.badge = null; item.isOnline = true; // 画像パターンは必ず「ログイン中」を表示 } } items.push(item); itemRegistry[item.id] = item; itemOrder.push(item.id); generatedCount++; } return items; } const ICONS = { carousel: ``, video: ``, }; const HEART = ``; const COMMENT = ``; function tileHtml(item) { const badgeHtml = item.videoSrc ? `
LIVE
` : (item.isOnline ? `
ログイン中
` : (item.badge ? `
${ICONS[item.badge]}
` : "")); // 最初の数枚は優先的に読み込む(遅延読み込みしない)ことでLCPを速くする const isAboveTheFold = item.id < 4; const mediaHtml = item.videoSrc ? `` : (isAboveTheFold ? `` : ``); return `
${badgeHtml} ${mediaHtml}
${HEART} ${formatCount(item.likes)} ${COMMENT} ${formatCount(item.comments)}
`; } const grid = document.getElementById("grid"); const sentinel = document.createElement("div"); sentinel.className = "sentinel"; // トリガー3用: TOP画面から詳細画面を開いた回数 let topToDetailOpenCount = 0; function appendBatch() { const items = fetchNextBatch(BATCH_SIZE); const html = items.map(tileHtml).join(""); sentinel.insertAdjacentHTML("beforebegin", html); grid.querySelectorAll(".tile:not([data-bound])").forEach(tile => { tile.setAttribute("data-bound", "1"); tile.addEventListener("click", () => { openDetail(tile.dataset.id); // トリガー3: TOPから詳細画面を開いた回数が4回目でモーダル表示 topToDetailOpenCount++; if (topToDetailOpenCount >= 5) { topToDetailOpenCount = 0; openEngageModal(itemRegistry[tile.dataset.id]); } }); }); observeNewVideos(); } function updateRowHeight() { const styles = getComputedStyle(document.documentElement); const cols = parseInt(styles.getPropertyValue("--cols")); const gapPx = parseFloat(styles.getPropertyValue("--gap")); const width = grid.clientWidth; const colWidth = (width - gapPx * (cols - 1)) / cols; // タイルは2行分(span 2)を使うため、実際の縦横比を「幅 x 比率」にするには // 1行あたりの高さを (幅 x 比率) / 2 にする必要がある const aspectRatio = 16 / 9; // 幅に対する高さの比率(画像サイズ 9:16 に合わせる) document.documentElement.style.setProperty("--row-h", (colWidth * aspectRatio / 2) + "px"); } // 無限スクロール: sentinelが画面内に入るたびに次のバッチを読み込み続ける(永久ループ) const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { appendBatch(); } }); }, { rootMargin: "800px" }); // 画面内に入った動画のみ再生し、外に出たら一時停止(帯域・パフォーマンス対策) const videoObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { const video = entry.target; if (entry.isIntersecting) { video.play().catch(() => {}); // 自動再生ブロック時は無視 } else { video.pause(); } }); }, { rootMargin: "200px", threshold: 0.25 }); function observeNewVideos() { grid.querySelectorAll("video:not([data-observed])").forEach(video => { video.setAttribute("data-observed", "1"); videoObserver.observe(video); }); } grid.appendChild(sentinel); appendBatch(); updateRowHeight(); window.addEventListener("resize", updateRowHeight); observer.observe(sentinel);