/*
* ==== 本番差し替えポイント ====
* 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);