Files
MoYuBan/assets/script.js
2025-11-19 15:11:50 +08:00

438 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 外部脚本文件:轻量的菜单高亮逻辑与占位
document.addEventListener('DOMContentLoaded', () => {
// 菜单点击激活态
const links = Array.from(document.querySelectorAll('.menu__link'));
links.forEach(link => {
link.addEventListener('click', () => {
links.forEach(l => l.classList.remove('is-active'));
link.classList.add('is-active');
});
});
// ===== 视频逻辑 =====
const player = document.getElementById('player');
const bgVideo = document.getElementById('bgVideo');
const videoWrap = document.getElementById('videoWrap');
const videoPanel = document.getElementById('video');
const includeLocalToggle = document.getElementById('includeLocalToggle');
const statFlowEl = document.getElementById('statFlow');
const statVisitEl = document.getElementById('statVisit');
const statVideoEl = document.getElementById('statVideo');
const statPicEl = document.getElementById('statPic');
const formatBytes = (n) => {
let val = Number(n) || 0;
const units = ['B','KB','MB','GB','TB','PB','EB','ZB','YB'];
let i = 0;
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
const digits = val >= 100 ? 0 : (val >= 10 ? 1 : 2);
return val.toFixed(digits) + ' ' + units[i];
};
const formatCount = (n) => {
if (n >= 100000000) return (n / 100000000).toFixed(n < 1000000000 ? 2 : 1) + ' 亿';
if (n >= 10000) return (n / 10000).toFixed(n < 100000 ? 2 : 1) + ' 万';
return new Intl.NumberFormat('zh-CN').format(n);
};
const updateStatsUI = (t) => {
if (!t) return;
const bw = (t.bandwidth_in ?? 0) + (t.bandwidth_out ?? 0);
if (statFlowEl) statFlowEl.textContent = formatBytes(bw);
if (statVisitEl) statVisitEl.textContent = formatCount(t.visits ?? 0);
if (statVideoEl) statVideoEl.textContent = formatCount(t.video_plays ?? 0);
if (statPicEl) statPicEl.textContent = formatCount(t.picture_shows ?? 0);
};
const track = async (payload) => {
try {
const r = await fetch('api/stats.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const j = await r.json();
if (j && j.success) {
const t = j.total || j.today;
updateStatsUI(t);
}
} catch {}
};
(async () => { try { const r = await fetch('api/stats.php'); const j = await r.json(); if (j && j.success) updateStatsUI(j.total || j.today); } catch {} })();
track({ event: 'visit', page: location.pathname });
// 底部工具栏与标题已移除,无需获取相关元素
if (!player || !videoWrap) return; // 仅在视频区存在时执行
let playlist = [];
let index = 0;
let includeLocal = includeLocalToggle ? includeLocalToggle.checked : false;
const BUFFER_SIZE = 3; // 预缓存条数
const loadVideo = (i, autoPlay = false) => {
if (!playlist.length) return;
index = (i + playlist.length) % playlist.length;
const item = playlist[index];
try { player.crossOrigin = 'anonymous'; } catch {}
player.src = item.url;
if (bgVideo) {
try {
bgVideo.muted = true;
bgVideo.loop = true;
bgVideo.setAttribute('playsinline', '');
bgVideo.src = item.url;
bgVideo.load();
bgVideo.play().catch(() => {});
} catch {}
}
// 已移除标题展示,无需设置文本
player.load();
if (videoPanel) videoPanel.style.setProperty('--progress', '0');
if (autoPlay) {
player.play().catch(() => {});
}
};
// 按需拉取一条或多条新视频
const fetchOne = async (n = 1) => {
try {
const url = `api/videos.php?count=${n}&include_local=${includeLocal ? 1 : 0}&local_count=${n}`;
const res = await fetch(url);
const data = await res.json();
const items = Array.isArray(data.list) ? data.list : [];
if (items.length) {
const existing = new Set(playlist.map(i => i.url));
const append = items.filter(it => it && it.url && !existing.has(it.url));
playlist = playlist.concat(append);
}
} catch (e) {
// 忽略错误
}
};
const ensureBuffer = async () => {
const ahead = playlist.length - index - 1;
if (ahead < BUFFER_SIZE) {
await fetchOne(BUFFER_SIZE - ahead);
}
};
const next = async () => {
await ensureBuffer();
if (index + 1 < playlist.length) {
loadVideo(index + 1, true);
} else if (!playlist.length) {
// 仍无数据,尝试拉取
await fetchOne(BUFFER_SIZE + 1);
if (playlist.length) loadVideo(0, true);
}
};
const prev = async () => {
if (index - 1 >= 0) {
loadVideo(index - 1, true);
}
};
// 鼠标移入显示并播放,移出隐藏并暂停
const showVideo = () => {
player.classList.add('visible');
player.play().catch(() => {});
// 用户首次交互后取消静音(可选)
if (player.muted) player.muted = false;
};
const hideVideo = () => {
player.pause();
player.classList.remove('visible');
// 可选player.currentTime = 0; // 回到开头
};
videoWrap.addEventListener('mouseenter', showVideo);
videoWrap.addEventListener('mouseleave', hideVideo);
// 页面加载时若鼠标已在视频区域,立即显示
if (videoWrap.matches(':hover')) showVideo();
// 单击下一条,双击上一条(去抖处理)
// 按最新需求:不再点击视频进入投稿,而是点击菜单栏“视频”进入投稿
let clickTimer = null;
videoWrap.addEventListener('click', () => {
if (clickTimer) return;
clickTimer = setTimeout(() => {
clickTimer = null;
next();
}, 250);
});
videoWrap.addEventListener('dblclick', () => {
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
prev();
});
// 自动保存:播放到一半时保存(每个视频仅保存一次)
const savedURLs = new Set();
const trySaveOnHalf = async () => {
const item = playlist[index];
if (!item) return;
// 本地视频不入库URL非http/https时跳过保存
if (!/^https?:\/\//i.test(item.url || '')) return;
const d = player.duration;
const t = player.currentTime;
if (!isFinite(d) || d <= 0) return; // 元数据未就绪
const reachedHalf = t / d >= 0.5;
if (!reachedHalf || savedURLs.has(item.url)) return;
try {
const res = await fetch('api/save_video.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: item.url, title: item.title || '' }),
});
const data = await res.json();
if (data && data.success) {
savedURLs.add(item.url);
}
} catch (e) {
// 忽略保存错误,不影响播放
}
};
player.addEventListener('timeupdate', trySaveOnHalf);
player.addEventListener('timeupdate', () => {
const d = player.duration;
const t = player.currentTime;
if (!isFinite(d) || d <= 0) return;
const p = Math.max(0, Math.min(1, t / d));
if (videoPanel) videoPanel.style.setProperty('--progress', String(p * 360));
if (!bgCtx) return;
const now = Date.now();
if (now - bgLastUpdate < 1500) return;
bgLastUpdate = now;
try {
bgCtx.drawImage(player, 0, 0, bgCanvas.width, bgCanvas.height);
const img = bgCtx.getImageData(0, 0, bgCanvas.width, bgCanvas.height).data;
let r = 0, g = 0, b = 0, cnt = 0;
for (let i = 0; i < img.length; i += 4) {
r += img[i]; g += img[i+1]; b += img[i+2]; cnt++;
}
if (cnt > 0) {
r = Math.round(r / cnt); g = Math.round(g / cnt); b = Math.round(b / cnt);
const mix = (c) => Math.round(c * 0.25 + 255 * 0.75);
const base = `rgb(${mix(r)},${mix(g)},${mix(b)})`;
const accent = `rgba(${r},${g},${b},0.2)`;
document.documentElement.style.setProperty('--bg-base', base);
document.documentElement.style.setProperty('--bg-accent', accent);
}
} catch {}
});
// 播放结束自动下一条
player.addEventListener('ended', () => { next(); });
const bgCanvas = document.createElement('canvas');
bgCanvas.width = 32; bgCanvas.height = 18;
const bgCtx = bgCanvas.getContext('2d', { willReadFrequently: true });
let bgLastUpdate = 0;
player.addEventListener('loadeddata', () => { bgLastUpdate = 0; });
let lastLoggedVideo = '';
player.addEventListener('play', () => {
const cur = player.currentSrc || player.src || '';
if (!cur || cur === lastLoggedVideo) return;
lastLoggedVideo = cur;
if (bgVideo) { try { bgVideo.play().catch(() => {}); } catch {} }
track({ event: 'video_play', page: location.pathname, url: cur, title: '' });
});
// 从后端接口获取视频列表
// 初始化:先拉取首条并填充缓存
(async () => {
await fetchOne(BUFFER_SIZE + 1);
if (!playlist.length) {
// 无视频可用
return;
}
loadVideo(0, false);
// 启动缓冲确保后续播放连续
await ensureBuffer();
})();
// 切换本地视频开关:重置播放列表并重新拉取
if (includeLocalToggle) {
includeLocalToggle.addEventListener('change', async () => {
includeLocal = includeLocalToggle.checked;
// 重置播放列表
playlist = [];
index = 0;
await fetchOne(BUFFER_SIZE + 1);
if (playlist.length) {
loadVideo(0, false);
await ensureBuffer();
}
});
}
// ===== 讨论区气泡逻辑 =====
const discussionWrap = document.getElementById('discussionWrap');
if (discussionWrap) {
const fetchAvatar = async () => {
try {
const r = await fetch('api/head.php');
const j = await r.json();
return j.url || '';
} catch { return ''; }
};
const fetchText = async () => {
try {
const r = await fetch('api/text.php'); // 随机来源
const j = await r.json();
return { text: j.text || '', source: j.source || '' };
} catch { return { text: '', source: '' }; }
};
const createBubble = (avatarUrl, text, side = Math.random() < 0.5 ? 'left' : 'right') => {
const item = document.createElement('div');
item.className = 'bubble' + (side === 'right' ? ' right' : '');
const img = document.createElement('img');
img.className = 'avatar';
img.src = avatarUrl || 'https://images.unsplash.com/photo-1547425260-76bcadfb4f9b?w=200&h=200&fit=crop';
img.alt = '头像';
const textEl = document.createElement('div');
textEl.className = 'bubble__text';
textEl.textContent = text || '...';
item.appendChild(img);
item.appendChild(textEl);
discussionWrap.appendChild(item);
// 滚动到底部
discussionWrap.scrollTop = discussionWrap.scrollHeight;
// 控制条数最多50条
const nodes = discussionWrap.querySelectorAll('.bubble');
if (nodes.length > 50) {
for (let i = 0; i < nodes.length - 50; i++) {
discussionWrap.removeChild(nodes[i]);
}
}
};
const savedAvatars = new Set();
const savedTexts = new Set();
const spawn = async () => {
const [avatar, textResp] = await Promise.all([fetchAvatar(), fetchText()]);
const text = textResp.text;
const source = textResp.source;
createBubble(avatar, text);
// 保存到本地,避免重复
try {
// 仅保存远程头像http/https本地相对路径跳过
if (avatar && /^https?:\/\//i.test(avatar) && !savedAvatars.has(avatar)) {
savedAvatars.add(avatar);
await fetch('api/save_avatar.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: avatar })
});
}
} catch {}
try {
const key = text.trim();
if (key && !savedTexts.has(key)) {
savedTexts.add(key);
await fetch('api/save_text.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: key, source })
});
}
} catch {}
};
// 立即生成一个然后每5秒生成一次可在此处调整间隔
spawn();
setInterval(spawn, 5000);
}
// ===== 图片区逻辑淡进淡出10s刷新自动保存不清理 =====
const pictureWrap = document.getElementById('pictureWrap');
const pictureImg = document.getElementById('pictureImg');
const galleryPanel = document.getElementById('gallery');
if (pictureWrap && pictureImg) {
const sources = ['heisi', 'wapmeinvpic', 'baisi', 'meinvpic'];
const savedPics = new Set();
const fetchPicture = async () => {
const type = sources[Math.floor(Math.random() * sources.length)];
try {
const r = await fetch(`api/picture.php?type=${encodeURIComponent(type)}`);
const j = await r.json();
return { url: j.url || '', source: j.source || type };
} catch {
return { url: '', source: type };
}
};
const showPicture = async () => {
// 先淡出
pictureImg.classList.remove('visible');
const { url, source } = await fetchPicture();
if (!url) return; // 拉取失败时跳过本次
// 等图片加载完成后再淡入
await new Promise(resolve => {
pictureImg.onload = () => { resolve(); };
pictureImg.onerror = () => { resolve(); };
pictureImg.src = url;
});
// 加载完成但不再自动显示,仅悬停时显示
// 自动保存(避免重复),后端不做清理
try {
// 仅保存远程图片http/https本地相对路径跳过
if (/^https?:\/\//i.test(url) && !savedPics.has(url)) {
savedPics.add(url);
await fetch('api/save_picture.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
}
} catch {}
};
// 悬停显隐
const showPic = () => pictureImg.classList.add('visible');
const hidePic = () => pictureImg.classList.remove('visible');
pictureWrap.addEventListener('mouseenter', showPic);
pictureWrap.addEventListener('mouseleave', hidePic);
// 页面加载时若鼠标已在图片区域,立即显示
if (pictureWrap.matches(':hover')) showPic();
let picIntervalMs = 10000;
let picLastSwap = Date.now();
const updatePicProgress = () => {
const elapsed = Date.now() - picLastSwap;
const p = Math.max(0, Math.min(1, elapsed / picIntervalMs));
if (galleryPanel) galleryPanel.style.setProperty('--pic-progress', String(p * 360));
requestAnimationFrame(updatePicProgress);
};
requestAnimationFrame(updatePicProgress);
(async () => {
const { url } = await fetchPicture();
if (url) {
pictureImg.src = url;
picLastSwap = Date.now();
if (galleryPanel) galleryPanel.style.setProperty('--pic-progress', '0');
track({ event: 'picture_show', page: location.pathname, url });
if (pictureWrap.matches(':hover')) showPic();
}
})();
setInterval(async () => {
const { url } = await fetchPicture();
if (url) {
pictureImg.src = url;
picLastSwap = Date.now();
if (galleryPanel) galleryPanel.style.setProperty('--pic-progress', '0');
track({ event: 'picture_show', page: location.pathname, url });
if (pictureWrap.matches(':hover')) showPic();
}
}, picIntervalMs);
}
});