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 (
PineSound
); } /* ========================================================= 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('播放列表为空,前往管理后台添加'); 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('播放列表已清空,前往管理后台添加'); 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 (
{/* Perspective Grid */}
{/* Ambient Orbs */}
{/* Loader */}
); }