Files
chatroom/resources/js/chat-room/earn-panel.js
T
2026-04-25 14:25:07 +08:00

400 lines
12 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.
// 聊天室赚钱面板,注册 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();
}