- 在右侧导航新增「赚钱」入口(娱乐下方)
- 新增 earn-panel 弹窗:风格与商店一致,800px 宽度
- 集成 FluidPlayer + VAST 广告(ExoClick)
- 动态倒计时:实时监听视频 duration/currentTime
- VAST 失败时自动回退保底视频,20s 超时保底放行
- 修复 AbortError:idle 时 video 不预播放,仅提供 fallback source
- 删除不支持的 player.on('error') 调用
- 所有 overlay 改用绝对定位居中,修复 Alpine x-show 破坏 flex 问题
- EarnController:Redis 每日 10 次限额 + 冷却防刷
- 领取成功后广播全服系统消息(含金币+经验+快捷入口标签)
- 移除神秘盒子相关 UI 代码
343 lines
18 KiB
PHP
343 lines
18 KiB
PHP
{{--
|
||
文件功能:观看广告赚钱面板
|
||
包含 ExoClick 广告展示和倒计时领奖逻辑,基于 Alpine.js
|
||
|
||
@author ChatRoom Laravel
|
||
@version 1.0.0
|
||
--}}
|
||
<div x-data="earnPanelData()"
|
||
@open-earn-panel.window="openPanel()"
|
||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
|
||
x-show="isOpen"
|
||
x-transition.opacity
|
||
style="display: none;">
|
||
|
||
<div style="background-color: #ffffff; border-radius: 8px; width: 800px; max-width: 95vw; max-height: 84vh; display: flex; flex-direction: column; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; position: relative;"
|
||
@click.outside="closePanel()">
|
||
|
||
{{-- 标题栏 — 与设置/商店/头像弹窗一致 --}}
|
||
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding: 8px 16px; color: white; background: linear-gradient(135deg, #336699, #5a8fc0);">
|
||
<div style="font-size: 14px; font-weight: bold; flex: 1;">💰 赚钱 - 看视频得金币经验</div>
|
||
<button @click="closePanel()" style="background: none; border: none; color: white; cursor: pointer; font-size: 18px; line-height: 1; opacity: 0.8;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.8">×</button>
|
||
</div>
|
||
|
||
{{-- 面板内容区域 --}}
|
||
<div style="padding: 16px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; align-items: center;">
|
||
|
||
{{-- 信息说明区域 --}}
|
||
<div style="background-color: #f0f6ff; border: 1px solid #cce0f5; border-radius: 4px; padding: 12px; margin-bottom: 16px; width: 100%; text-align: center;">
|
||
<div style="color: #336699; font-weight: bold; margin-bottom: 8px; font-size: 14px;">
|
||
完整观看视频后,即可获得 <span style="color: #d97706;">5000 金币</span> 和 <span style="color: #16a34a;">500 经验</span> 奖励!<br>
|
||
</div>
|
||
<div style="font-size: 12px; color: #6b7280;">
|
||
中途关闭视作放弃,不会扣除观影次数。每日最多获取 10 次。
|
||
</div>
|
||
</div>
|
||
|
||
{{-- 视频广告容器 --}}
|
||
<div style="width: 100%; aspect-ratio: 16/9; background-color: #e2e8f0; border: 1px solid #cbd5e1; border-radius: 4px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; margin-bottom: 16px;">
|
||
|
||
{{-- video 元素常驻 DOM,preload=none 防止空闲时预加载。
|
||
必须保留 fallback source:VAST 失败时 FluidPlayer 会调用 playMainVideoWhenVastFails(),
|
||
它会 load() 到此 source,若 source 为空则找不到目标,仍会触发 AbortError。 --}}
|
||
<div id="video-wrapper" style="width: 100%; height: 100%; background-color: #000; display: flex; align-items: center; justify-content: center;">
|
||
<video id="exoclick-video" style="width: 100%; height: 100%;" playsinline preload="none">
|
||
<source src="https://cdn.fluidplayer.com/videos/valerian-1080p.mkv" type="video/mp4" />
|
||
</video>
|
||
</div>
|
||
|
||
{{-- Idle Overlay (空闲时覆盖在视频之上) --}}
|
||
{{-- 注意:不用 display:flex 居中,因为 Alpine x-show 恢复时只设 display:block 会覆盖 flex --}}
|
||
<div x-show="status === 'idle'" style="position: absolute; inset: 0; background-color: #e2e8f0; z-index: 10;">
|
||
<button @click="startWatching()" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: linear-gradient(135deg, #336699, #5a8fc0); color: #ffffff; padding: 12px 32px; border-radius: 4px; font-weight: bold; border: none; font-size: 15px; cursor: pointer; white-space: nowrap;">
|
||
▶ 开始观看
|
||
</button>
|
||
</div>
|
||
|
||
{{-- Completed Overlay (完毕覆盖层) --}}
|
||
<div x-show="status === 'completed'" style="position: absolute; inset: 0; background-color: #f0fdf4; z-index: 10;">
|
||
<span style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #16a34a; font-weight: bold; font-size: 28px; white-space: nowrap;">✅ 观看完毕!</span>
|
||
</div>
|
||
|
||
{{-- Claiming Overlay (领奖覆盖层) --}}
|
||
<div x-show="status === 'claiming' || status === 'claimed'" style="position: absolute; inset: 0; background-color: #fefce8; z-index: 10;">
|
||
<div x-show="status === 'claiming'" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #d97706; font-weight: bold; font-size: 16px; white-space: nowrap;">
|
||
⏳ 正在发放奖励...
|
||
</div>
|
||
<div x-show="status === 'claimed'" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;">
|
||
<div style="color: #d97706; font-weight: bold; font-size: 24px;">🎉 奖励已到账</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- 浮动倒计时器 --}}
|
||
<div x-show="status === 'watching'" style="position: absolute; bottom: 8px; right: 8px; background-color: rgba(0,0,0,0.6); color: white; font-family: monospace; padding: 4px 12px; border-radius: 4px; font-size: 12px; z-index: 50;">
|
||
进度: <span x-text="countdown"></span>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- 底部按钮区域 --}}
|
||
<div style="width: 100%;">
|
||
<template x-if="status === 'completed'">
|
||
<button @click="claimReward()" style="width: 100%; background-color: #16a34a; color: #ffffff; padding: 10px 24px; border-radius: 4px; font-weight: bold; border: none; font-size: 14px; cursor: pointer;">
|
||
点击领取奖励
|
||
</button>
|
||
</template>
|
||
|
||
<template x-if="status === 'claimed'">
|
||
<button @click="resetPanel()" style="width: 100%; background-color: #64748b; color: #ffffff; padding: 10px 24px; border-radius: 4px; font-weight: bold; border: none; font-size: 14px; cursor: pointer;">
|
||
关闭面板
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- 引入 Fluid Player 用于播放 VAST 广告 -->
|
||
<link rel="stylesheet" href="https://cdn.fluidplayer.com/v3/current/fluidplayer.min.css" type="text/css"/>
|
||
<script src="https://cdn.fluidplayer.com/v3/current/fluidplayer.min.js"></script>
|
||
|
||
<script>
|
||
document.addEventListener('alpine:init', () => {
|
||
Alpine.data('earnPanelData', () => ({
|
||
isOpen: false,
|
||
status: 'idle', // idle, watching, completed, claiming, claimed
|
||
countdown: '获取中...',
|
||
timer: null,
|
||
player: null,
|
||
videoFinished: false,
|
||
|
||
openPanel() {
|
||
this.isOpen = true;
|
||
if (this.status !== 'claimed' && this.status !== 'claiming') {
|
||
this.resetPanelState();
|
||
}
|
||
},
|
||
|
||
closePanel() {
|
||
this.isOpen = false;
|
||
this.stopTimer();
|
||
if (this.status === 'watching') {
|
||
window.chatToast.show({ title: '取消提示', message: '已取消观看,没有扣除观影次数。', icon: 'ℹ️', color: '#3b82f6' });
|
||
}
|
||
if (this.player) {
|
||
try { this.player.destroy(); } catch(e) {}
|
||
this.player = null;
|
||
}
|
||
this.restoreVideoDOM();
|
||
this.status = 'idle';
|
||
},
|
||
|
||
resetPanelState() {
|
||
this.status = 'idle';
|
||
this.countdown = '获取中...';
|
||
this.videoFinished = false;
|
||
this.stopTimer();
|
||
if (this.player) {
|
||
try { this.player.destroy(); } catch(e) {}
|
||
this.player = null;
|
||
}
|
||
this.restoreVideoDOM();
|
||
},
|
||
|
||
resetPanel() {
|
||
this.closePanel();
|
||
setTimeout(() => { this.resetPanelState(); }, 300);
|
||
},
|
||
|
||
restoreVideoDOM() {
|
||
// FluidPlayer 销毁后会修改/包裹 video 元素,需要重建以便下次干净初始化
|
||
// source 必须保留,否则 VAST 失败时 FluidPlayer 的 fallback 无目标可播
|
||
const wrapper = document.getElementById('video-wrapper');
|
||
if (wrapper) {
|
||
wrapper.innerHTML = `<video id="exoclick-video" style="width: 100%; height: 100%;" playsinline preload="none"><source src="https://cdn.fluidplayer.com/videos/valerian-1080p.mkv" type="video/mp4" /></video>`;
|
||
}
|
||
},
|
||
|
||
completeWatch() {
|
||
this.videoFinished = true;
|
||
this.stopTimer();
|
||
this.status = 'completed';
|
||
|
||
if (this.player) {
|
||
try { this.player.destroy(); } catch(e) {}
|
||
this.player = null;
|
||
}
|
||
this.restoreVideoDOM();
|
||
|
||
if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') {
|
||
EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{});
|
||
}
|
||
},
|
||
|
||
startWatching() {
|
||
this.status = 'watching';
|
||
this.countdown = '请求广告...';
|
||
this.videoFinished = false;
|
||
this.stopTimer();
|
||
|
||
// 同步初始化播放器,保证满足浏览器的 user gesture 直接挂钩播放
|
||
this.initPlayer();
|
||
|
||
let elapsedTime = 0;
|
||
|
||
// 基于视频真实时长的倒计时检测与失败降级处理
|
||
this.timer = setInterval(() => {
|
||
if (this.videoFinished) return;
|
||
|
||
const v = document.getElementById('exoclick-video');
|
||
elapsedTime += 0.5;
|
||
|
||
if (v) {
|
||
const dur = v.duration;
|
||
const cur = v.currentTime;
|
||
|
||
// 当系统取到真实的视频时长时(确保不是NaN)
|
||
if (!isNaN(dur) && dur > 0) {
|
||
const left = Math.ceil(dur - cur);
|
||
this.countdown = (left > 0 ? left : 0) + ' 秒';
|
||
|
||
// 若倒计时归零并且正在播放中(保证不是误判)
|
||
if (left <= 0 && cur > 0) {
|
||
this.completeWatch();
|
||
}
|
||
} else {
|
||
// 没有视频时长(可能正在缓冲,也可能广告没有填充)
|
||
// 最多让用户干等 20 秒作为无广告情况下的保底奖励
|
||
const fallbackLeft = Math.ceil(20 - elapsedTime);
|
||
if (fallbackLeft > 0) {
|
||
this.countdown = '缓冲/加载中... ' + fallbackLeft + 's';
|
||
} else {
|
||
// 20秒超时还没获得时长,则当作广告未填充或网速太慢,放行奖励
|
||
this.completeWatch();
|
||
}
|
||
}
|
||
}
|
||
}, 500);
|
||
},
|
||
|
||
initPlayer() {
|
||
// 按照 Chrome 规范:确保 video 当前未在 play() 中,通过 pause() 后再初始化
|
||
// 参考:https://developer.chrome.com/blog/play-request-was-interrupted
|
||
const v = document.getElementById('exoclick-video');
|
||
const doInit = () => {
|
||
try {
|
||
this.player = 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: 'https://s.magsrv.com/v1/vast.php?idzone=5889208',
|
||
adText: '请观看广告获取金币'
|
||
}
|
||
]
|
||
}
|
||
}
|
||
);
|
||
|
||
// 注意:FluidPlayer 不支持 player.on('error'),不要注册该事件
|
||
// VAST 失败时 FluidPlayer 会自动播放 fallback source,
|
||
// 我们的 setInterval 轮询 currentTime/duration 会自然触发 completeWatch()
|
||
|
||
} catch(e) {
|
||
console.error('[EarnPanel] 播放器初始化未成功', e);
|
||
}
|
||
};
|
||
|
||
if (v && !v.paused) {
|
||
// 如果 video 正在播放,先等 pause 后再 init,避免 play() 被 load() 中断
|
||
v.pause();
|
||
v.addEventListener('pause', doInit, { once: true });
|
||
} else {
|
||
doInit();
|
||
}
|
||
},
|
||
|
||
stopTimer() {
|
||
if (this.timer) {
|
||
clearInterval(this.timer);
|
||
this.timer = null;
|
||
}
|
||
},
|
||
|
||
async claimReward() {
|
||
if (this.status !== 'completed') return;
|
||
this.status = 'claiming';
|
||
try {
|
||
const payload = {
|
||
room_id: window.chatContext?.roomId || 0
|
||
};
|
||
|
||
const response = await fetch(window.chatContext.earnRewardUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||
'Accept': 'application/json'
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok && data.success) {
|
||
this.status = 'claimed';
|
||
window.chatToast.show({ title: '奖励到账', message: data.message || '领取成功!', icon: '🎉', color: '#10b981' });
|
||
|
||
// 更新本地上下文
|
||
if (data.new_jjb !== undefined) {
|
||
window.chatContext.userJjb = data.new_jjb;
|
||
window.chatContext.myGold = data.new_jjb;
|
||
// 派发更新全局 UI 的事件,类似钓鱼的更新
|
||
window.dispatchEvent(new CustomEvent('update-user-points', { detail: { points: data.new_jjb }}));
|
||
}
|
||
|
||
// 播放到账特定金币音效
|
||
if (typeof EffectManager !== 'undefined' && typeof EffectManager.audioManager !== 'undefined') {
|
||
EffectManager.audioManager.sounds.gold_received?.play().catch(()=>{});
|
||
}
|
||
|
||
if (data.level_up) {
|
||
window.chatToast.show({
|
||
title: '等级提升',
|
||
message: `恭喜!您的等级提升到了 ${data.new_level_name}!`,
|
||
icon: '🌟',
|
||
color: '#8b5cf6',
|
||
duration: 5000
|
||
});
|
||
setTimeout(() => {
|
||
// 触发烟花庆贺
|
||
if(typeof EffectManager !== 'undefined') {
|
||
EffectManager.play('fireworks');
|
||
}
|
||
}, 500);
|
||
}
|
||
} else {
|
||
// 领奖失败,可能是超出次数或防刷限制
|
||
this.status = 'completed';
|
||
window.chatToast.show({ title: '出错了', message: data.message || '领奖失败,请稍后再试。', icon: '❌', color: '#ef4444', duration: 4000 });
|
||
}
|
||
} catch (error) {
|
||
this.status = 'completed';
|
||
window.chatToast.show({ title: '网络错误', message: '网络请求失败,请检查连接。', icon: '⚠️', color: '#ef4444' });
|
||
}
|
||
}
|
||
}));
|
||
});
|
||
</script>
|