551 lines
18 KiB
React
551 lines
18 KiB
React
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import * as api from '../utils/api';
|
|
import { API_BASE } from '../utils/api';
|
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
import { enable, disable } from '@tauri-apps/plugin-autostart';
|
|
import '../styles/screen.css';
|
|
|
|
/* ============ Loader ============ */
|
|
function Loader({ hidden, text }) {
|
|
return (
|
|
<div className={`loader${hidden ? ' hidden' : ''}`}>
|
|
<div className="loader-brand">
|
|
<div className="mark">
|
|
<div className="iso-right"></div>
|
|
<div className="iso-dot"></div>
|
|
</div>
|
|
<div className="label">PineSound</div>
|
|
</div>
|
|
<div className="loader-ring">
|
|
<div className="ring"></div>
|
|
<div className="ring"></div>
|
|
<div className="ring"></div>
|
|
</div>
|
|
<div className="loader-hint" dangerouslySetInnerHTML={{ __html: text }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* =========================================================
|
|
Screen Page
|
|
========================================================= */
|
|
export default function Screen() {
|
|
const navigate = useNavigate();
|
|
const [list, setList] = useState([]);
|
|
const [index, setIndex] = useState(0);
|
|
const [volume, setVolume] = useState(0.8);
|
|
const [playMode, setPlayMode] = useState('sequential');
|
|
const [imageDuration, setImageDuration] = useState(5);
|
|
const [paused, setPaused] = useState(false);
|
|
const [soundBlocked, setSoundBlocked] = useState(false);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [loaderText, setLoaderText] = useState('加载播放列表中...');
|
|
const [mediaKey, setMediaKey] = useState(0); // 强制重新渲染,用于单项目循环
|
|
const [showVideo, setShowVideo] = useState(true);
|
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
const [loginUsername, setLoginUsername] = useState('');
|
|
const [loginPassword, setLoginPassword] = useState('');
|
|
const [loginError, setLoginError] = useState('');
|
|
|
|
const videoRef = useRef(null);
|
|
const imgRef = useRef(null);
|
|
const timerRef = useRef(null);
|
|
const loginTimerRef = useRef(null);
|
|
const loginInputRef = useRef(null);
|
|
|
|
// ref 存储可变值,避免回调中的闭包过期
|
|
const listRef = useRef(list);
|
|
const indexRef = useRef(index);
|
|
const pausedRef = useRef(paused);
|
|
const playModeRef = useRef(playMode);
|
|
const imageDurationRef = useRef(imageDuration);
|
|
const soundBlockedRef = useRef(soundBlocked);
|
|
|
|
// 从后端同步的显示设置(Screen 上无需 UI 控制,但需要响应 SSE)
|
|
const [fullscreenMode, setFullscreenMode] = useState(true);
|
|
const [autostartEnabled, setAutostartEnabled] = useState(false);
|
|
|
|
useEffect(() => { listRef.current = list; }, [list]);
|
|
useEffect(() => { indexRef.current = index; }, [index]);
|
|
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
|
useEffect(() => { playModeRef.current = playMode; }, [playMode]);
|
|
useEffect(() => { imageDurationRef.current = imageDuration; }, [imageDuration]);
|
|
useEffect(() => { soundBlockedRef.current = soundBlocked; }, [soundBlocked]);
|
|
|
|
const getUrl = useCallback((item) => {
|
|
return item.source === 'url' ? item.relative_path : `${API_BASE}/file/${item.relative_path}`;
|
|
}, []);
|
|
|
|
// ============ 核心播放控制 ============
|
|
|
|
/** 稳定的 next — 始终推进索引(不检查 paused),永不停止 */
|
|
const next = useCallback(() => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
const len = listRef.current.length;
|
|
if (len === 0) return;
|
|
const mode = playModeRef.current;
|
|
setIndex(prev => {
|
|
if (mode === 'random') {
|
|
return Math.floor(Math.random() * len);
|
|
}
|
|
// 关键:始终循环,(prev + 1) % len 保证永不越界
|
|
return (prev + 1) % len;
|
|
});
|
|
// 单项目时 index 不变((0+1)%1=0),用 mediaKey 强制渲染
|
|
setMediaKey(k => k + 1);
|
|
}, []);
|
|
|
|
const skip = useCallback((delta) => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
const len = listRef.current.length;
|
|
if (len === 0) return;
|
|
const mode = playModeRef.current;
|
|
setIndex(prev => {
|
|
if (mode === 'random') {
|
|
return Math.floor(Math.random() * len);
|
|
}
|
|
return (prev + delta + len) % len;
|
|
});
|
|
setMediaKey(k => k + 1);
|
|
}, []);
|
|
|
|
// ============ 播放效果 ============
|
|
|
|
// index / mediaKey 变化时播放对应项
|
|
useEffect(() => {
|
|
if (!loaded || list.length === 0 || paused) return;
|
|
const item = list[index];
|
|
if (!item) return;
|
|
|
|
// 同步状态到后端(字段名 type 与 Rust PlaybackState 的 #[serde(rename = "type")] 匹配)
|
|
api.updateState({
|
|
status: 'playing',
|
|
index,
|
|
name: item.name || '',
|
|
type: item.type || '',
|
|
}).catch(() => {});
|
|
|
|
if (item.type === 'video') {
|
|
setShowVideo(true);
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const url = getUrl(item);
|
|
video.src = url;
|
|
video.load();
|
|
video.muted = soundBlocked;
|
|
video.volume = volume;
|
|
const p = video.play();
|
|
if (p !== undefined) {
|
|
p.catch(() => {
|
|
setSoundBlocked(true);
|
|
video.muted = true;
|
|
video.play().catch(() => {});
|
|
});
|
|
}
|
|
} else {
|
|
// 图片
|
|
setShowVideo(false);
|
|
const url = getUrl(item);
|
|
imgRef.current.src = url;
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(next, imageDuration * 1000);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [index, mediaKey, loaded, paused]);
|
|
|
|
// 恢复播放:paused 从 true → false 时触发
|
|
useEffect(() => {
|
|
if (paused || !loaded || list.length === 0) return;
|
|
const item = list[index];
|
|
if (!item || item.type !== 'video') return;
|
|
const video = videoRef.current;
|
|
if (video && video.paused && video.src) {
|
|
video.play().catch(() => {
|
|
setSoundBlocked(true);
|
|
video.muted = true;
|
|
video.play().catch(() => {});
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [paused]);
|
|
|
|
// ============ 事件监听 ============
|
|
|
|
// Video ended → next(稳定,永不失效)
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const handler = () => next();
|
|
video.addEventListener('ended', handler);
|
|
return () => video.removeEventListener('ended', handler);
|
|
// mediaKey 变化时 video 元素被 key 强制重建,需要重新绑定
|
|
}, [next, mediaKey]);
|
|
|
|
// 加载播放列表
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const data = await api.getPlaylist();
|
|
const files = data.files || [];
|
|
const vol = (data.volume || 80) / 100;
|
|
setList(files);
|
|
setVolume(vol);
|
|
setPlayMode(data.play_mode || 'sequential');
|
|
setImageDuration(data.image_duration || 5);
|
|
if (videoRef.current) videoRef.current.volume = vol;
|
|
|
|
if (files.length === 0) {
|
|
setLoaderText('播放列表为空,<a href="/admin">前往管理后台添加</a>');
|
|
return;
|
|
}
|
|
setLoaded(true);
|
|
|
|
// 尝试启用声音
|
|
const video = videoRef.current;
|
|
if (video) {
|
|
video.muted = false;
|
|
const testPlay = video.play();
|
|
if (testPlay !== undefined) {
|
|
testPlay.then(() => {
|
|
setSoundBlocked(false);
|
|
video.pause();
|
|
}).catch(() => {
|
|
setSoundBlocked(true);
|
|
video.muted = true;
|
|
});
|
|
}
|
|
}
|
|
} catch {/* ignore */}
|
|
};
|
|
load();
|
|
}, []);
|
|
|
|
// SSE 连接
|
|
useEffect(() => {
|
|
const es = new EventSource(`${API_BASE}/api/events`);
|
|
es.onmessage = (e) => {
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
switch (msg.action) {
|
|
case 'pause':
|
|
setPaused(true);
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
if (videoRef.current) videoRef.current.pause();
|
|
break;
|
|
case 'play':
|
|
setPaused(false);
|
|
// 由 paused → false 的 useEffect 自动恢复播放
|
|
break;
|
|
case 'next':
|
|
setPaused(false);
|
|
skip(1);
|
|
break;
|
|
case 'prev':
|
|
setPaused(false);
|
|
skip(-1);
|
|
break;
|
|
case 'minimize_window':
|
|
getCurrentWindow().minimize().catch(() => {});
|
|
break;
|
|
case 'playlist_changed':
|
|
reloadPlaylist();
|
|
break;
|
|
case 'settings_changed':
|
|
applySettings(msg);
|
|
break;
|
|
}
|
|
} catch {/* ignore */}
|
|
};
|
|
return () => es.close();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [skip]);
|
|
|
|
const reloadPlaylist = useCallback(async () => {
|
|
try {
|
|
const data = await api.getPlaylist();
|
|
const newList = data.files || [];
|
|
const currentItem = listRef.current[indexRef.current] || null;
|
|
|
|
if (newList.length === 0) {
|
|
setList([]);
|
|
setLoaded(false);
|
|
setLoaderText('播放列表已清空,<a href="/admin">前往管理后台添加</a>');
|
|
const video = videoRef.current;
|
|
if (video) video.pause();
|
|
return;
|
|
}
|
|
|
|
setLoaded(true);
|
|
|
|
let newIndex = -1;
|
|
if (currentItem) {
|
|
newIndex = newList.findIndex(item => item.relative_path === currentItem.relative_path);
|
|
}
|
|
if (newIndex >= 0) {
|
|
setList(newList);
|
|
setIndex(newIndex);
|
|
} else {
|
|
setList(newList);
|
|
setIndex(prev => Math.min(prev, newList.length - 1));
|
|
}
|
|
} catch {/* ignore */}
|
|
}, []);
|
|
|
|
const applySettings = useCallback((msg) => {
|
|
if (msg.volume !== undefined) {
|
|
const vol = msg.volume / 100;
|
|
setVolume(vol);
|
|
const video = videoRef.current;
|
|
if (video) {
|
|
video.volume = vol;
|
|
if (soundBlockedRef.current) {
|
|
video.muted = false;
|
|
video.play().then(() => {
|
|
setSoundBlocked(false);
|
|
const hint = document.getElementById('soundHint');
|
|
if (hint) hint.style.display = 'none';
|
|
}).catch(() => {
|
|
video.muted = true;
|
|
video.play().catch(() => {});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (msg.play_mode !== undefined) setPlayMode(msg.play_mode);
|
|
if (msg.image_duration !== undefined) setImageDuration(msg.image_duration);
|
|
if (msg.fullscreen !== undefined) {
|
|
setFullscreenMode(msg.fullscreen);
|
|
getCurrentWindow().setFullscreen(msg.fullscreen).catch(() => {});
|
|
}
|
|
if (msg.autostart !== undefined) {
|
|
setAutostartEnabled(msg.autostart);
|
|
if (msg.autostart) {
|
|
enable().catch(() => {});
|
|
} else {
|
|
disable().catch(() => {});
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const enableSound = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.muted = false;
|
|
video.play().then(() => {
|
|
setSoundBlocked(false);
|
|
const hint = document.getElementById('soundHint');
|
|
if (hint) hint.style.display = 'none';
|
|
}).catch(() => {
|
|
video.muted = true;
|
|
video.play().catch(() => {});
|
|
});
|
|
}, []);
|
|
|
|
// ============ 点击标题弹出登录弹窗 ============
|
|
const handleTitleClick = useCallback(() => {
|
|
setLoginUsername('');
|
|
setLoginPassword('');
|
|
setLoginError('');
|
|
setShowLoginModal(true);
|
|
// 30 秒无操作自动关闭
|
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
|
loginTimerRef.current = setTimeout(() => {
|
|
setShowLoginModal(false);
|
|
}, 30000);
|
|
// 下一帧聚焦输入框
|
|
setTimeout(() => {
|
|
if (loginInputRef.current) loginInputRef.current.focus();
|
|
}, 100);
|
|
}, []);
|
|
|
|
const handleCloseLogin = useCallback(() => {
|
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
|
setShowLoginModal(false);
|
|
}, []);
|
|
|
|
const handleLoginSubmit = useCallback(async () => {
|
|
try {
|
|
const data = await api.login(loginUsername, loginPassword);
|
|
if (data.success) {
|
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
|
localStorage.setItem('token', 'admin_logged');
|
|
navigate('/admin?from=screen');
|
|
} else {
|
|
setLoginError('账号或密码错误');
|
|
}
|
|
} catch {
|
|
setLoginError('登录失败,请重试');
|
|
}
|
|
}, [loginUsername, loginPassword, navigate]);
|
|
|
|
const handleLoginKeyDown = useCallback((e) => {
|
|
if (e.key === 'Enter') handleLoginSubmit();
|
|
}, [handleLoginSubmit]);
|
|
|
|
// ============ LIVE 点击切换播放/暂停 ============
|
|
const togglePlayPause = useCallback(() => {
|
|
if (pausedRef.current) {
|
|
// 恢复播放
|
|
setPaused(false);
|
|
} else {
|
|
// 暂停
|
|
setPaused(true);
|
|
const video = videoRef.current;
|
|
if (video) video.pause();
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
}
|
|
}, []);
|
|
|
|
// Global click for sound unblock
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
if (soundBlockedRef.current) enableSound();
|
|
};
|
|
document.addEventListener('click', handler);
|
|
return () => document.removeEventListener('click', handler);
|
|
}, [enableSound]);
|
|
|
|
// 加载显示设置(大屏/小窗口、开机自启动)
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const data = await api.getSettings();
|
|
setFullscreenMode(data.fullscreen ?? true);
|
|
setAutostartEnabled(data.autostart ?? false);
|
|
} catch {/* ignore */}
|
|
};
|
|
load();
|
|
}, []);
|
|
|
|
// Escape 退出 Tauri 窗口全屏 / 关闭登录弹窗
|
|
useEffect(() => {
|
|
const handler = (e) => {
|
|
if (e.key === 'Escape') {
|
|
if (showLoginModal) {
|
|
handleCloseLogin();
|
|
} else {
|
|
getCurrentWindow().setFullscreen(false).catch(() => {});
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [showLoginModal, handleCloseLogin]);
|
|
|
|
// Cleanup timers on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const currentItem = list[index] || null;
|
|
|
|
return (
|
|
<div className="screen-page">
|
|
{/* Perspective Grid */}
|
|
<div className="perspective-grid"></div>
|
|
{/* Ambient Orbs */}
|
|
<div className="ambient-orb orb-1"></div>
|
|
<div className="ambient-orb orb-2"></div>
|
|
<div className="ambient-orb orb-3"></div>
|
|
|
|
{/* Loader */}
|
|
<Loader hidden={loaded && list.length > 0} text={loaderText} />
|
|
|
|
{/* Media Players — key={mediaKey} 强制重建以实现单项目循环 */}
|
|
<video
|
|
ref={videoRef}
|
|
id="player"
|
|
key={mediaKey}
|
|
autoPlay
|
|
playsInline
|
|
controlsList="nodownload"
|
|
style={{ display: showVideo && loaded ? 'block' : 'none' }}
|
|
/>
|
|
<img
|
|
ref={imgRef}
|
|
id="imgPlayer"
|
|
alt=""
|
|
style={{ display: !showVideo && loaded ? 'block' : 'none' }}
|
|
/>
|
|
|
|
{/* Corner Info — Now Playing */}
|
|
<div
|
|
className={`corner-info${loaded && currentItem ? ' visible' : ''}`}
|
|
onClick={handleTitleClick}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<div className="ci-dot"></div>
|
|
<span className="ci-text">{'昆明市大学生创业园'}</span>
|
|
</div>
|
|
|
|
{/* Connection Indicator — 点击切换播放/暂停 */}
|
|
<div className={`conn-indicator${loaded ? ' visible' : ''}`} onClick={togglePlayPause}>
|
|
<div className="conn-rings">
|
|
<div className={`cr-inner${paused ? ' paused' : ''}`}></div>
|
|
<div className="cr-outer"></div>
|
|
</div>
|
|
<span className="conn-label">{paused ? 'Paused' : 'Live'}</span>
|
|
</div>
|
|
|
|
{/* Bottom Status — 左侧点击上一段,右侧点击下一段 */}
|
|
<div className={`status-bar${loaded ? ' visible' : ''}`}>
|
|
<div className="sb-prev" onClick={() => skip(-1)}>
|
|
<div className={`sb-dot${paused ? ' idle' : ''}`}></div>
|
|
<span className="sb-text">{currentItem ? `${index + 1}` : '0'}</span>
|
|
</div>
|
|
<span className="sb-sep">/</span>
|
|
<div className="sb-next" onClick={() => skip(1)}>
|
|
<span className="sb-text">{list.length}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Login Modal */}
|
|
<div className={`login-overlay${showLoginModal ? ' visible' : ''}`} onClick={(e) => { if (e.target === e.currentTarget) handleCloseLogin(); }}>
|
|
<div className="login-modal" onClick={e => e.stopPropagation()}>
|
|
<button className="login-modal-close" onClick={handleCloseLogin}>×</button>
|
|
<div className="login-modal-brand">
|
|
<h2>后台管理</h2>
|
|
<p>请输入账号密码登录</p>
|
|
</div>
|
|
<div className="login-modal-field">
|
|
<label>账号</label>
|
|
<input
|
|
ref={loginInputRef}
|
|
value={loginUsername}
|
|
onChange={e => { setLoginUsername(e.target.value); setLoginError(''); }}
|
|
onKeyDown={handleLoginKeyDown}
|
|
placeholder="请输入账号"
|
|
autoComplete="username"
|
|
/>
|
|
</div>
|
|
<div className="login-modal-field">
|
|
<label>密码</label>
|
|
<input
|
|
type="password"
|
|
value={loginPassword}
|
|
onChange={e => { setLoginPassword(e.target.value); setLoginError(''); }}
|
|
onKeyDown={handleLoginKeyDown}
|
|
placeholder="请输入密码"
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
{loginError && <div className="login-modal-error">{loginError}</div>}
|
|
<button className="login-modal-btn" onClick={handleLoginSubmit}>登 录</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Watermark */}
|
|
<div className="watermark">云南派音人工智能科技提供技术支持</div>
|
|
|
|
{/* Sound Hint */}
|
|
<div id="soundHint" style={{ display: soundBlocked && loaded ? 'block' : 'none' }} onClick={enableSound}>
|
|
声音
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|