Files

343 lines
18 KiB
PHP
Raw Permalink 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.
{{--
文件功能:观看广告赚钱面板
包含 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">&times;</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;">
中途关闭视作放弃,不会扣除观影次数。每日最多获取 3 次。
</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 元素常驻 DOMpreload=none 防止空闲时预加载。
必须保留 fallback sourceVAST 失败时 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 广告 -->
<!-- v3 CDN 版本已内嵌样式,不能再引用已下线的 fluidplayer.min.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>