400 lines
12 KiB
JavaScript
400 lines
12 KiB
JavaScript
|
|
// 聊天室赚钱面板,注册 earnPanelData Alpine 组件并集中管理 FluidPlayer 加载。
|
|||
|
|
|
|||
|
|
const FLUID_PLAYER_SCRIPT_URL = "https://cdn.fluidplayer.com/v3/current/fluidplayer.min.js";
|
|||
|
|
const FALLBACK_VIDEO_SOURCE = "https://cdn.fluidplayer.com/videos/valerian-1080p.mkv";
|
|||
|
|
const VAST_TAG_URL = "https://s.magsrv.com/v1/vast.php?idzone=5889208";
|
|||
|
|
|
|||
|
|
let earnPanelControlsBound = false;
|
|||
|
|
let earnPanelRegistered = false;
|
|||
|
|
let fluidPlayerScriptPromise = null;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 读取 CSRF Token。
|
|||
|
|
*
|
|||
|
|
* @returns {string}
|
|||
|
|
*/
|
|||
|
|
function csrf() {
|
|||
|
|
return document.querySelector('meta[name="csrf-token"]')?.content || "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 显示统一 Toast,避免面板代码直接依赖未初始化对象。
|
|||
|
|
*
|
|||
|
|
* @param {object} options
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
function showToast(options) {
|
|||
|
|
window.chatToast?.show?.(options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 按需加载 FluidPlayer CDN 脚本。
|
|||
|
|
*
|
|||
|
|
* @returns {Promise<void>}
|
|||
|
|
*/
|
|||
|
|
function loadFluidPlayerScript() {
|
|||
|
|
if (typeof window.fluidPlayer === "function") {
|
|||
|
|
return Promise.resolve();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (fluidPlayerScriptPromise) {
|
|||
|
|
return fluidPlayerScriptPromise;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fluidPlayerScriptPromise = new Promise((resolve, reject) => {
|
|||
|
|
const existingScript = document.querySelector(`script[src="${FLUID_PLAYER_SCRIPT_URL}"]`);
|
|||
|
|
if (existingScript) {
|
|||
|
|
existingScript.addEventListener("load", () => resolve(), { once: true });
|
|||
|
|
existingScript.addEventListener("error", () => reject(new Error("FluidPlayer 加载失败")), { once: true });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const script = document.createElement("script");
|
|||
|
|
script.src = FLUID_PLAYER_SCRIPT_URL;
|
|||
|
|
script.async = true;
|
|||
|
|
script.addEventListener("load", () => resolve(), { once: true });
|
|||
|
|
script.addEventListener("error", () => reject(new Error("FluidPlayer 加载失败")), { once: true });
|
|||
|
|
document.head.appendChild(script);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return fluidPlayerScriptPromise;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 销毁播放器实例并吞掉第三方库的重复销毁异常。
|
|||
|
|
*
|
|||
|
|
* @param {object|null} player
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
function destroyPlayer(player) {
|
|||
|
|
if (!player) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
player.destroy();
|
|||
|
|
} catch (error) {
|
|||
|
|
// FluidPlayer 重复销毁时可能抛错,面板关闭流程应继续执行。
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 重建原始 video DOM,避免 FluidPlayer 包裹后的节点影响下一次初始化。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
function restoreVideoDOM() {
|
|||
|
|
const wrapper = document.getElementById("video-wrapper");
|
|||
|
|
if (!wrapper) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
wrapper.innerHTML = `<video id="exoclick-video" style="width: 100%; height: 100%;" playsinline preload="none"><source src="${FALLBACK_VIDEO_SOURCE}" type="video/mp4" /></video>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 创建赚钱面板 Alpine 组件。
|
|||
|
|
*
|
|||
|
|
* @returns {object}
|
|||
|
|
*/
|
|||
|
|
export function createEarnPanelData() {
|
|||
|
|
return {
|
|||
|
|
isOpen: false,
|
|||
|
|
status: "idle",
|
|||
|
|
countdown: "获取中...",
|
|||
|
|
timer: null,
|
|||
|
|
player: null,
|
|||
|
|
videoFinished: false,
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 打开面板并提前加载播放器脚本。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
openPanel() {
|
|||
|
|
this.isOpen = true;
|
|||
|
|
void loadFluidPlayerScript().catch(() => {
|
|||
|
|
showToast({ title: "播放器加载失败", message: "广告播放器暂时不可用,请稍后再试。", icon: "⚠️", color: "#ef4444" });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (this.status !== "claimed" && this.status !== "claiming") {
|
|||
|
|
this.resetPanelState();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 关闭面板并释放播放器资源。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
closePanel() {
|
|||
|
|
this.isOpen = false;
|
|||
|
|
this.stopTimer();
|
|||
|
|
|
|||
|
|
if (this.status === "watching") {
|
|||
|
|
showToast({ title: "取消提示", message: "已取消观看,没有扣除观影次数。", icon: "ℹ️", color: "#3b82f6" });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
destroyPlayer(this.player);
|
|||
|
|
this.player = null;
|
|||
|
|
restoreVideoDOM();
|
|||
|
|
this.status = "idle";
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 重置面板到待观看状态。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
resetPanelState() {
|
|||
|
|
this.status = "idle";
|
|||
|
|
this.countdown = "获取中...";
|
|||
|
|
this.videoFinished = false;
|
|||
|
|
this.stopTimer();
|
|||
|
|
destroyPlayer(this.player);
|
|||
|
|
this.player = null;
|
|||
|
|
restoreVideoDOM();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 领奖后延迟重置,给用户保留到账反馈。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
resetPanel() {
|
|||
|
|
this.closePanel();
|
|||
|
|
window.setTimeout(() => {
|
|||
|
|
this.resetPanelState();
|
|||
|
|
}, 300);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 标记观看完成并清理播放器。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
completeWatch() {
|
|||
|
|
this.videoFinished = true;
|
|||
|
|
this.stopTimer();
|
|||
|
|
this.status = "completed";
|
|||
|
|
destroyPlayer(this.player);
|
|||
|
|
this.player = null;
|
|||
|
|
restoreVideoDOM();
|
|||
|
|
window.EffectSounds?.ding?.();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 开始观看广告并启动进度轮询。
|
|||
|
|
*
|
|||
|
|
* @returns {Promise<void>}
|
|||
|
|
*/
|
|||
|
|
async startWatching() {
|
|||
|
|
this.status = "watching";
|
|||
|
|
this.countdown = "请求广告...";
|
|||
|
|
this.videoFinished = false;
|
|||
|
|
this.stopTimer();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await loadFluidPlayerScript();
|
|||
|
|
} catch (error) {
|
|||
|
|
this.status = "idle";
|
|||
|
|
showToast({ title: "播放器加载失败", message: "广告播放器暂时不可用,请稍后再试。", icon: "⚠️", color: "#ef4444" });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.initPlayer();
|
|||
|
|
let elapsedTime = 0;
|
|||
|
|
|
|||
|
|
this.timer = window.setInterval(() => {
|
|||
|
|
if (this.videoFinished) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const video = document.getElementById("exoclick-video");
|
|||
|
|
elapsedTime += 0.5;
|
|||
|
|
|
|||
|
|
if (!video) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const duration = video.duration;
|
|||
|
|
const currentTime = video.currentTime;
|
|||
|
|
|
|||
|
|
if (!Number.isNaN(duration) && duration > 0) {
|
|||
|
|
const left = Math.ceil(duration - currentTime);
|
|||
|
|
this.countdown = `${left > 0 ? left : 0} 秒`;
|
|||
|
|
|
|||
|
|
if (left <= 0 && currentTime > 0) {
|
|||
|
|
this.completeWatch();
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 无广告填充或缓冲过慢时,最多等待 20 秒后放行奖励。
|
|||
|
|
const fallbackLeft = Math.ceil(20 - elapsedTime);
|
|||
|
|
if (fallbackLeft > 0) {
|
|||
|
|
this.countdown = `缓冲/加载中... ${fallbackLeft}s`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.completeWatch();
|
|||
|
|
}, 500);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始化 FluidPlayer 实例。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
initPlayer() {
|
|||
|
|
const video = document.getElementById("exoclick-video");
|
|||
|
|
const doInit = () => {
|
|||
|
|
try {
|
|||
|
|
this.player = window.fluidPlayer("exoclick-video", {
|
|||
|
|
layoutControls: {
|
|||
|
|
primaryColor: "#336699",
|
|||
|
|
posterImage: false,
|
|||
|
|
playButtonShowing: true,
|
|||
|
|
playPauseAnimation: false,
|
|||
|
|
fillToContainer: true,
|
|||
|
|
autoPlay: true,
|
|||
|
|
mute: false,
|
|||
|
|
},
|
|||
|
|
vastOptions: {
|
|||
|
|
allowVPAID: true,
|
|||
|
|
vastTimeout: 8000,
|
|||
|
|
adList: [
|
|||
|
|
{
|
|||
|
|
roll: "preRoll",
|
|||
|
|
vastTag: VAST_TAG_URL,
|
|||
|
|
adText: "请观看广告获取金币",
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
// 第三方播放器初始化失败时保留面板,用户可关闭后重试。
|
|||
|
|
console.error("[EarnPanel] 播放器初始化未成功", error);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (video && !video.paused) {
|
|||
|
|
video.pause();
|
|||
|
|
video.addEventListener("pause", doInit, { once: true });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
doInit();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 停止观看进度计时器。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
stopTimer() {
|
|||
|
|
if (!this.timer) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.clearInterval(this.timer);
|
|||
|
|
this.timer = null;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 向后端领取观看奖励。
|
|||
|
|
*
|
|||
|
|
* @returns {Promise<void>}
|
|||
|
|
*/
|
|||
|
|
async claimReward() {
|
|||
|
|
if (this.status !== "completed") {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.status = "claiming";
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(window.chatContext.earnRewardUrl, {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: {
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
"X-CSRF-TOKEN": csrf(),
|
|||
|
|
"Accept": "application/json",
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
room_id: window.chatContext?.roomId || 0,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (response.ok && data.success) {
|
|||
|
|
this.status = "claimed";
|
|||
|
|
showToast({ title: "奖励到账", message: data.message || "领取成功!", icon: "🎉", color: "#10b981" });
|
|||
|
|
|
|||
|
|
if (data.new_jjb !== undefined) {
|
|||
|
|
window.chatContext.userJjb = data.new_jjb;
|
|||
|
|
window.chatContext.myGold = data.new_jjb;
|
|||
|
|
window.dispatchEvent(new CustomEvent("update-user-points", { detail: { points: data.new_jjb } }));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.EffectSounds?.ding?.();
|
|||
|
|
|
|||
|
|
if (data.level_up) {
|
|||
|
|
showToast({
|
|||
|
|
title: "等级提升",
|
|||
|
|
message: `恭喜!您的等级提升到了 ${data.new_level_name}!`,
|
|||
|
|
icon: "🌟",
|
|||
|
|
color: "#8b5cf6",
|
|||
|
|
duration: 5000,
|
|||
|
|
});
|
|||
|
|
window.setTimeout(() => {
|
|||
|
|
window.EffectManager?.play?.("fireworks");
|
|||
|
|
}, 500);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.status = "completed";
|
|||
|
|
showToast({ title: "出错了", message: data.message || "领奖失败,请稍后再试。", icon: "❌", color: "#ef4444", duration: 4000 });
|
|||
|
|
} catch (error) {
|
|||
|
|
this.status = "completed";
|
|||
|
|
showToast({ title: "网络错误", message: "网络请求失败,请检查连接。", icon: "⚠️", color: "#ef4444" });
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 注册赚钱面板 Alpine 组件。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
function registerEarnPanelData() {
|
|||
|
|
if (earnPanelRegistered || !window.Alpine) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
earnPanelRegistered = true;
|
|||
|
|
window.Alpine.data("earnPanelData", createEarnPanelData);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 绑定赚钱面板入口和 Alpine 注册事件。
|
|||
|
|
*
|
|||
|
|
* @returns {void}
|
|||
|
|
*/
|
|||
|
|
export function bindEarnPanelControls() {
|
|||
|
|
if (typeof window === "undefined" || earnPanelControlsBound) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
earnPanelControlsBound = true;
|
|||
|
|
window.createEarnPanelData = createEarnPanelData;
|
|||
|
|
window.openEarnPanel = () => {
|
|||
|
|
window.dispatchEvent(new CustomEvent("open-earn-panel"));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
document.addEventListener("alpine:init", registerEarnPanelData, { once: true });
|
|||
|
|
registerEarnPanelData();
|
|||
|
|
}
|